Refactor and code designing

This commit is contained in:
2026-03-02 03:30:22 +01:00
parent 6d7465ff18
commit e231f4ed05
80 changed files with 6690 additions and 4670 deletions

View File

@@ -147,9 +147,7 @@
} @else {
@for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
{{ user.displayName ? user.displayName.charAt(0).toUpperCase() : '?' }}
</div>
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
@@ -302,7 +300,11 @@
<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" />
<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">
@@ -310,7 +312,11 @@
<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" />
<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">
@@ -318,7 +324,11 @@
<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" />
<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">
@@ -326,7 +336,11 @@
<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" />
<input
type="checkbox"
[(ngModel)]="moderatorsManageIcon"
class="w-4 h-4 accent-primary"
/>
</div>
</div>
@@ -346,28 +360,16 @@
<!-- 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>
<app-confirm-dialog
title="Delete Room"
confirmLabel="Delete Room"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="deleteRoom()"
(cancelled)="showDeleteConfirm.set(false)"
>
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
</app-confirm-dialog>
}
} @else {
<div class="h-full flex items-center justify-center text-muted-foreground">

View File

@@ -16,8 +16,8 @@ import {
lucideUnlock,
} from '@ng-icons/lucide';
import * as UsersActions from '../../../store/users/users.actions';
import * as RoomsActions from '../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import {
selectBannedUsers,
@@ -27,13 +27,14 @@ import {
} from '../../../store/users/users.selectors';
import { BanEntry, Room, User } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
@Component({
selector: 'app-admin-panel',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
viewProviders: [
provideIcons({
lucideShield,
@@ -50,6 +51,10 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
],
templateUrl: './admin-panel.component.html',
})
/**
* Admin panel for managing room settings, members, bans, and permissions.
* Only accessible to users with admin privileges.
*/
export class AdminPanelComponent {
private store = inject(Store);
private webrtc = inject(WebRTCService);
@@ -99,10 +104,12 @@ export class AdminPanelComponent {
}
}
/** Toggle the room's private visibility setting. */
togglePrivate(): void {
this.isPrivate.update((v) => !v);
this.isPrivate.update((current) => !current);
}
/** Save the current room name, description, privacy, and max-user settings. */
saveSettings(): void {
const room = this.currentRoom();
if (!room) return;
@@ -120,6 +127,7 @@ export class AdminPanelComponent {
);
}
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
savePermissions(): void {
const room = this.currentRoom();
if (!room) return;
@@ -141,14 +149,17 @@ export class AdminPanelComponent {
);
}
/** Remove a user's ban entry. */
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
}
/** Show the delete-room confirmation dialog. */
confirmDeleteRoom(): void {
this.showDeleteConfirm.set(true);
}
/** Delete the current room after confirmation. */
deleteRoom(): void {
const room = this.currentRoom();
if (!room) return;
@@ -157,17 +168,20 @@ export class AdminPanelComponent {
this.showDeleteConfirm.set(false);
}
/** Format a ban expiry timestamp into a human-readable date/time string. */
formatExpiry(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Members tab: get all users except self
/** Return online users excluding the current user (for the members list). */
membersFiltered(): User[] {
const me = this.currentUser();
return this.onlineUsers().filter(u => u.id !== me?.id && u.oderId !== me?.oderId);
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
}
/** Change a member's role and broadcast the update to all peers. */
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.webrtc.broadcastMessage({
@@ -177,6 +191,7 @@ export class AdminPanelComponent {
});
}
/** Kick a member from the server and broadcast the action to peers. */
kickMember(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.webrtc.broadcastMessage({
@@ -186,6 +201,7 @@ export class AdminPanelComponent {
});
}
/** Ban a member from the server and broadcast the action to peers. */
banMember(user: User): void {
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
this.webrtc.broadcastMessage({

View File

@@ -8,20 +8,40 @@
<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" />
<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" />
<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
[(ngModel)]="serverId"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
>
@for (s of servers(); track s.id) {
<option [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>
@if (error()) {
<p 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>

View File

@@ -8,8 +8,9 @@ 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 { UsersActions } from '../../../store/users/users.actions';
import { User } from '../../../core/models';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({
selector: 'app-login',
@@ -18,6 +19,9 @@ import { User } from '../../../core/models';
viewProviders: [provideIcons({ lucideLogIn })],
templateUrl: './login.component.html',
})
/**
* Login form allowing existing users to authenticate against a selected server.
*/
export class LoginComponent {
private auth = inject(AuthService);
private serversSvc = inject(ServerDirectoryService);
@@ -30,8 +34,10 @@ export class LoginComponent {
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
/** TrackBy function for server list rendering. */
trackById(_index: number, item: { id: string }) { return item.id; }
/** Validate and submit the login form, then navigate to search on success. */
submit() {
this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id;
@@ -47,7 +53,7 @@ export class LoginComponent {
role: 'member',
joinedAt: Date.now(),
};
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.router.navigate(['/search']);
},
@@ -57,6 +63,7 @@ export class LoginComponent {
});
}
/** Navigate to the registration page. */
goRegister() {
this.router.navigate(['/register']);
}

View File

@@ -8,24 +8,48 @@
<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" />
<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" />
<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" />
<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
[(ngModel)]="serverId"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
>
@for (s of servers(); track s.id) {
<option [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>
@if (error()) {
<p 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>

View File

@@ -8,8 +8,9 @@ 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 { UsersActions } from '../../../store/users/users.actions';
import { User } from '../../../core/models';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({
selector: 'app-register',
@@ -18,6 +19,9 @@ import { User } from '../../../core/models';
viewProviders: [provideIcons({ lucideUserPlus })],
templateUrl: './register.component.html',
})
/**
* Registration form allowing new users to create an account on a selected server.
*/
export class RegisterComponent {
private auth = inject(AuthService);
private serversSvc = inject(ServerDirectoryService);
@@ -31,8 +35,10 @@ export class RegisterComponent {
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
/** TrackBy function for server list rendering. */
trackById(_index: number, item: { id: string }) { return item.id; }
/** Validate and submit the registration form, then navigate to search on success. */
submit() {
this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id;
@@ -48,7 +54,7 @@ export class RegisterComponent {
role: 'member',
joinedAt: Date.now(),
};
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.router.navigate(['/search']);
},
@@ -58,6 +64,7 @@ export class RegisterComponent {
});
}
/** Navigate to the login page. */
goLogin() {
this.router.navigate(['/login']);
}

View File

@@ -13,11 +13,15 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
templateUrl: './user-bar.component.html',
})
/**
* Compact user status bar showing the current user with login/register navigation links.
*/
export class UserBarComponent {
private store = inject(Store);
private router = inject(Router);
user = this.store.selectSignal(selectCurrentUser);
/** Navigate to the specified authentication page. */
goto(path: 'login' | 'register') {
this.router.navigate([`/${path}`]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,462 @@
<div class="chat-layout relative h-full">
<!-- Messages List -->
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">
<!-- Syncing indicator -->
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
<span>Syncing messages…</span>
</div>
}
@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 {
<!-- Infinite scroll: load-more sentinel at top -->
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
} @else {
<button (click)="loadMore()" class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary">
Load older messages
</button>
}
</div>
}
@for (message of messages(); track message.id) {
<div
[attr.data-message-id]="message.id"
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
[class.opacity-50]="message.isDeleted"
>
<!-- Avatar -->
<app-user-avatar [name]="message.senderName" size="md" class="flex-shrink-0" />
<!-- Message Content -->
<div class="flex-1 min-w-0">
<!-- Reply indicator -->
@if (message.replyToId) {
@let repliedMsg = getRepliedMessage(message.replyToId);
<div class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" (click)="scrollToMessage(message.replyToId)">
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
<ng-icon name="lucideReply" class="w-3 h-3" />
@if (repliedMsg) {
<span class="font-medium">{{ repliedMsg.senderName }}</span>
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
} @else {
<span class="italic">Original message not found</span>
}
</div>
}
<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 {
<div class="chat-markdown mt-1 break-words">
<remark [markdown]="message.content" [processor]="remarkProcessor">
<ng-template [remarkTemplate]="'code'" let-node>
@if (node.lang === 'mermaid') {
<remark-mermaid [code]="node.value"/>
} @else {
<pre><code>{{ node.value }}</code></pre>
}
</ng-template>
</remark>
</div>
@if (getAttachments(message.id).length > 0) {
<div class="mt-2 space-y-2">
@for (att of getAttachments(message.id); track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<!-- Available image with hover overlay -->
<div class="relative group/img inline-block" (contextmenu)="openImageContextMenu($event, att)">
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="rounded-md max-h-80 w-auto cursor-pointer"
(click)="openLightbox(att)"
/>
<div class="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors rounded-md pointer-events-none"></div>
<div class="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity flex gap-1">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="View full size"
>
<ng-icon name="lucideExpand" class="w-4 h-4" />
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-4 h-4" />
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<!-- Downloading in progress -->
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div class="h-full rounded-full bg-primary transition-all duration-300" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
</div>
</div>
} @else {
<!-- Unavailable — waiting for source -->
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-muted-foreground" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div class="text-xs text-muted-foreground/70 mt-0.5 italic">Waiting for image source…</div>
</div>
</div>
<button
(click)="retryImageRequest(att, message.id)"
class="mt-2 w-full px-3 py-1.5 text-xs bg-secondary hover:bg-secondary/80 text-foreground rounded-md transition-colors"
>
Retry
</button>
</div>
}
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!isUploader(att)) {
@if (!att.available) {
<div class="w-24 h-1.5 rounded bg-muted">
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-2">
<span>{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
(click)="requestAttachment(att, message.id)"
>Request</button>
} @else {
<button
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
(click)="cancelAttachment(att, message.id)"
>Cancel</button>
}
} @else {
<button
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
(click)="downloadAttachment(att)"
>Download</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</div>
</div>
</div>
}
}
</div>
}
}
<!-- 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>
<!-- Bottom bar: floats over messages -->
<div #bottomBar class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<!-- Reply Preview -->
@if (replyTo()) {
<div class="px-4 py-2 bg-secondary/50 flex items-center gap-2 pointer-events-auto">
<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 -->
<app-typing-indicator />
<!-- Markdown Toolbar -->
@if (toolbarVisible()) {
<div class="pointer-events-auto" (mousedown)="$event.preventDefault()" (mouseenter)="onToolbarMouseEnter()" (mouseleave)="onToolbarMouseLeave()">
<div class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm">
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('**')"><b>B</b></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('*')"><i>I</i></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('~~')"><s>S</s></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline(inlineCodeToken)">&#96;</button>
<span class="mx-1 text-muted-foreground">|</span>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(1)">H1</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(2)">H2</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(3)">H3</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('> ')">Quote</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('- ')">• List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyOrderedList()">1. List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyCodeBlock()">Code</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyLink()">Link</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyImage()">Image</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHorizontalRule()">HR</button>
</div>
</div>
}
<!-- Message Input -->
<div class="p-4 border-border">
<div
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
>
<textarea
#messageInputRef
rows="1"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(keydown.enter)="onEnter($event)"
(input)="onInputChange(); autoResizeTextarea()"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
class="chat-textarea w-full pl-3 pr-12 py-2 rounded-2xl border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-primary]="dragActive()"
[class.border-dashed]="dragActive()"
[class.ctrl-resize]="ctrlHeld()"
></textarea>
<button
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0"
class="send-btn absolute right-2 bottom-[15px] w-8 h-8 rounded-full bg-primary text-primary-foreground grid place-items-center hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
[class.visible]="inputHovered() || messageContent.trim().length > 0"
>
<ng-icon name="lucideSend" class="w-4 h-4" />
</button>
@if (dragActive()) {
<div class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center">
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</div>
}
@if (pendingFiles.length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (file of pendingFiles; track file.name) {
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button (click)="removePendingFile(file)" class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5">Remove</button>
</div>
}
</div>
}
</div>
</div>
</div>
<!-- Image Lightbox Modal -->
@if (lightboxAttachment()) {
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
(click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"
tabindex="0"
#lightboxBackdrop
>
<div class="relative max-w-[90vw] max-h-[90vh]" (click)="$event.stopPropagation()">
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<!-- Top-right action bar -->
<div class="absolute top-3 right-3 flex gap-2">
<button
(click)="downloadAttachment(lightboxAttachment()!)"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-5 h-5" />
</button>
<button
(click)="closeLightbox()"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Close"
>
<ng-icon name="lucideX" class="w-5 h-5" />
</button>
</div>
<!-- Bottom info bar -->
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-lg">
<span class="text-white text-sm">{{ lightboxAttachment()!.filename }}</span>
<span class="text-white/60 text-xs ml-2">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
</div>
</div>
</div>
}
<!-- Image Context Menu -->
@if (imageContextMenu()) {
<app-context-menu [x]="imageContextMenu()!.x" [y]="imageContextMenu()!.y" (closed)="closeImageContextMenu()">
<button (click)="copyImageToClipboard(imageContextMenu()!.attachment)" class="context-menu-item-icon">
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
Copy Image
</button>
<button (click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()" class="context-menu-item-icon">
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
Save Image
</button>
</app-context-menu>
}

View File

@@ -0,0 +1,223 @@
/* ── Chat layout: messages scroll behind input ──── */
.chat-layout {
display: flex;
flex-direction: column;
}
.chat-messages-scroll {
/* Fallback; dynamically overridden by updateScrollPadding() */
padding-bottom: 120px;
}
.chat-bottom-bar {
pointer-events: auto;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background: hsl(var(--background) / 0.85);
/* Inset from right so the scrollbar track stays visible */
right: 8px;
}
/* Gradient fade-in above the bottom bar — blur only, no darkening */
.chat-bottom-fade {
height: 20px;
background: transparent;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
}
/* ── Chat textarea redesign ────────────────────── */
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
background: var(--textarea-bg);
/* Auto-resize: start at 62px, grow upward */
height: 62px;
min-height: 62px;
max-height: 520px;
overflow-y: hidden;
resize: none;
transition: height 0.12s ease;
/* Show manual resize handle only while Ctrl is held */
&.ctrl-resize {
resize: vertical;
}
}
/* Send button: hidden by default, fades in on wrapper hover */
.send-btn {
opacity: 0;
pointer-events: none;
transform: scale(0.85);
transition:
opacity 0.2s ease,
transform 0.2s ease;
&.visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}
/* ── ngx-remark markdown styles ──────────────── */
.chat-markdown {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 0.9375rem;
line-height: 1.5;
color: hsl(var(--foreground));
::ng-deep {
remark {
display: contents;
}
p {
margin: 0.25em 0;
}
strong {
font-weight: 700;
color: hsl(var(--foreground));
}
em {
font-style: italic;
}
del {
text-decoration: line-through;
opacity: 0.7;
}
a {
color: hsl(var(--primary));
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
margin: 0.5em 0 0.25em;
color: hsl(var(--foreground));
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.15em; }
ul, ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li {
margin: 0.125em 0;
}
blockquote {
border-left: 3px solid hsl(var(--primary) / 0.5);
margin: 0.5em 0;
padding: 0.25em 0.75em;
color: hsl(var(--muted-foreground));
background: hsl(var(--secondary) / 0.3);
border-radius: 0 var(--radius) var(--radius) 0;
}
code {
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
background: hsl(var(--secondary));
padding: 0.15em 0.35em;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
}
pre {
overflow-x: auto;
max-width: 100%;
margin: 0.5em 0;
padding: 0.75em 1em;
background: hsl(var(--secondary));
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
code {
background: transparent;
padding: 0;
border-radius: 0;
white-space: pre;
word-break: normal;
}
}
hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 0.75em 0;
}
table {
border-collapse: collapse;
margin: 0.5em 0;
font-size: 0.875em;
width: auto;
max-width: 100%;
overflow-x: auto;
display: block;
}
th, td {
border: 1px solid hsl(var(--border));
padding: 0.35em 0.75em;
text-align: left;
}
th {
background: hsl(var(--secondary));
font-weight: 600;
}
img {
max-width: 100%;
height: auto;
max-height: 320px;
border-radius: var(--radius);
display: block;
}
// Ensure consecutive paragraphs have minimal spacing for chat feel
p + p {
margin-top: 0.25em;
}
// Mermaid diagrams: prevent SVG from blocking clicks on the rest of the app
remark-mermaid {
display: block;
overflow-x: auto;
max-width: 100%;
svg {
pointer-events: none;
max-width: 100%;
height: auto;
}
}
}
}

View File

@@ -0,0 +1,996 @@
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AttachmentService, Attachment } from '../../../core/services/attachment.service';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSend,
lucideSmile,
lucideEdit,
lucideTrash2,
lucideReply,
lucideMoreVertical,
lucideCheck,
lucideX,
lucideDownload,
lucideExpand,
lucideImage,
lucideCopy,
} from '@ng-icons/lucide';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { Subscription } from 'rxjs';
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
import { RemarkModule, MermaidComponent } from 'ngx-remark';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
@Component({
selector: 'app-chat-messages',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent],
viewProviders: [
provideIcons({
lucideSend,
lucideSmile,
lucideEdit,
lucideTrash2,
lucideReply,
lucideMoreVertical,
lucideCheck,
lucideX,
lucideDownload,
lucideExpand,
lucideImage,
lucideCopy,
}),
],
templateUrl: './chat-messages.component.html',
styleUrls: ['./chat-messages.component.scss'],
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
host: {
'(document:keydown)': 'onDocKeydown($event)',
'(document:keyup)': 'onDocKeyup($event)',
},
})
/**
* Real-time chat messages view with infinite scroll, markdown rendering,
* emoji reactions, file attachments, and image lightbox support.
*/
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
@ViewChild('bottomBar') bottomBar!: ElementRef;
private store = inject(Store);
private webrtc = inject(WebRTCService);
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
private cdr = inject(ChangeDetectorRef);
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
remarkProcessor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks) as any;
private allMessages = this.store.selectSignal(selectAllMessages);
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
// --- Infinite scroll (upwards) pagination ---
private readonly PAGE_SIZE = 50;
displayLimit = signal(this.PAGE_SIZE);
loadingMore = signal(false);
/** All messages for the current channel (full list, unsliced) */
private allChannelMessages = computed(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter(message =>
message.roomId === roomId && (message.channelId || 'general') === channelId
);
});
/** Paginated view — only the most recent `displayLimit` messages */
messages = computed(() => {
const all = this.allChannelMessages();
const limit = this.displayLimit();
if (all.length <= limit) return all;
return all.slice(all.length - limit);
});
/** Whether there are older messages that can be loaded */
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
loading = this.store.selectSignal(selectMessagesLoading);
syncing = this.store.selectSignal(selectMessagesSyncing);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
private currentRoom = this.store.selectSignal(selectCurrentRoom);
messageContent = '';
editContent = '';
editingMessageId = signal<string | null>(null);
replyTo = signal<Message | null>(null);
showEmojiPicker = signal<string | null>(null);
readonly commonEmojis = COMMON_EMOJIS;
private shouldScrollToBottom = true;
/** Keeps us pinned to bottom while images/attachments load after initial open */
private initialScrollObserver: MutationObserver | null = null;
private initialScrollTimer: any = null;
private boundOnImageLoad: (() => void) | null = null;
/** True while a programmatic scroll-to-bottom is in progress (suppresses onScroll). */
private isAutoScrolling = false;
private lastTypingSentAt = 0;
private lastMessageCount = 0;
private initialScrollPending = true;
pendingFiles: File[] = [];
// New messages snackbar state
showNewMessagesBar = signal(false);
// Plain (non-reactive) reference time used only by formatTimestamp.
// Updated periodically but NOT a signal, so it won't re-render every message.
private nowRef = Date.now();
private nowTimer: any;
toolbarVisible = signal(false);
private toolbarHovering = false;
inlineCodeToken = '`';
dragActive = signal(false);
inputHovered = signal(false);
ctrlHeld = signal(false);
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null;
// Image lightbox modal state
lightboxAttachment = signal<Attachment | null>(null);
// Image right-click context menu state
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null;
// Reset scroll state when room/server changes (handles reuse of component on navigation)
private onRoomChanged = effect(() => {
void this.currentRoom(); // track room signal
this.initialScrollPending = true;
this.stopInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE);
});
// Reset pagination when switching channels within the same room
private onChannelChanged = effect(() => {
void this.activeChannelId(); // track channel signal
this.displayLimit.set(this.PAGE_SIZE);
this.initialScrollPending = true;
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
});
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
private attachmentsUpdatedEffect = effect(() => {
void this.attachmentsSvc.updated();
this.cdr.markForCheck();
});
// Track total channel messages (not paginated) for new-message detection
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
messagesLength = computed(() => this.messages().length);
private onMessagesChanged = effect(() => {
const currentCount = this.totalChannelMessagesLength();
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 instantly (no animation)
// Only proceed once messages are actually rendered in the DOM
if (this.initialScrollPending) {
if (this.messages().length > 0) {
this.initialScrollPending = false;
// Snap to bottom immediately, then keep watching for late layout changes
this.isAutoScrolling = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { this.isAutoScrolling = false; });
this.startInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
} else if (!this.loading()) {
// Room has no messages and loading is done
this.initialScrollPending = false;
this.lastMessageCount = 0;
}
return;
}
this.updateScrollPadding();
}
ngOnInit(): void {
// Initialize message count for snackbar trigger
this.lastMessageCount = this.messages().length;
// Update reference time silently (non-reactive) so formatTimestamp
// uses a reasonably fresh "now" without re-rendering every message.
this.nowTimer = setInterval(() => {
this.nowRef = Date.now();
}, 60000);
// Global Escape key listener for lightbox & context menu
this.boundOnKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
}
};
document.addEventListener('keydown', this.boundOnKeydown);
}
ngOnDestroy(): void {
this.stopInitialScrollWatch();
if (this.nowTimer) {
clearInterval(this.nowTimer);
this.nowTimer = null;
}
if (this.boundOnKeydown) {
document.removeEventListener('keydown', this.boundOnKeydown);
}
}
/** Send the current message content (with optional attachments) and reset the input. */
sendMessage(): void {
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0) return;
const content = this.appendImageMarkdown(raw);
this.store.dispatch(
MessagesActions.sendMessage({
content,
replyToId: this.replyTo()?.id,
channelId: this.activeChannelId(),
})
);
this.messageContent = '';
this.clearReply();
this.shouldScrollToBottom = true;
// Reset textarea height after sending
requestAnimationFrame(() => this.autoResizeTextarea());
this.showNewMessagesBar.set(false);
if (this.pendingFiles.length > 0) {
// Wait briefly for the message to appear in the list, then attach
setTimeout(() => this.attachFilesToLastOwnMessage(content), 100);
}
}
/** Throttle and broadcast a typing indicator when the user types. */
onInputChange(): void {
const now = Date.now();
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
try {
this.webrtc.sendRawMessage({ type: 'typing' });
this.lastTypingSentAt = now;
} catch {}
}
}
/** Begin editing an existing message, populating the edit input. */
startEdit(message: Message): void {
this.editingMessageId.set(message.id);
this.editContent = message.content;
}
/** Save the edited message content and exit edit mode. */
saveEdit(messageId: string): void {
if (!this.editContent.trim()) return;
this.store.dispatch(
MessagesActions.editMessage({
messageId,
content: this.editContent.trim(),
})
);
this.cancelEdit();
}
/** Cancel the current edit and clear the edit state. */
cancelEdit(): void {
this.editingMessageId.set(null);
this.editContent = '';
}
/** Delete a message (own or admin-delete if the user has admin privileges). */
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 }));
}
}
/** Set the message to reply to. */
setReplyTo(message: Message): void {
this.replyTo.set(message);
}
/** Clear the current reply-to reference. */
clearReply(): void {
this.replyTo.set(null);
}
/** Find the original message that a reply references. */
getRepliedMessage(messageId: string): Message | undefined {
return this.allMessages().find(message => message.id === messageId);
}
/** Smooth-scroll to a specific message element and briefly highlight it. */
scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement;
if (!container) return;
const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-primary/10');
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
}
}
/** Toggle the emoji picker for a message. */
toggleEmojiPicker(messageId: string): void {
this.showEmojiPicker.update((current) =>
current === messageId ? null : messageId
);
}
/** Add a reaction emoji to a message. */
addReaction(messageId: string, emoji: string): void {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
this.showEmojiPicker.set(null);
}
/** Toggle the reaction for the current user on a message. */
toggleReaction(messageId: string, emoji: string): void {
const message = this.messages().find((msg) => msg.id === messageId);
const currentUserId = this.currentUser()?.id;
if (!message || !currentUserId) return;
const hasReacted = message.reactions.some(
(reaction) => reaction.emoji === emoji && reaction.userId === currentUserId
);
if (hasReacted) {
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
} else {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
}
}
/** Check whether a message was sent by the current user. */
isOwnMessage(message: Message): boolean {
return message.senderId === this.currentUser()?.id;
}
/** Aggregate reactions by emoji, returning counts and whether the current user reacted. */
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,
}));
}
/** Format a timestamp as a relative or absolute time string. */
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date(this.nowRef);
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
if (dayDiff === 0) {
return time;
} else if (dayDiff === 1) {
return 'Yesterday ' + time;
} else if (dayDiff < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
}
}
private scrollToBottom(): void {
if (this.messagesContainer) {
const el = this.messagesContainer.nativeElement;
el.scrollTop = el.scrollHeight;
this.shouldScrollToBottom = false;
}
}
/**
* Start observing the messages container for DOM mutations
* and image load events. Every time the container's content
* changes size (new nodes, images finishing load) we instantly
* snap to the bottom. Automatically stops after a timeout or
* when the user scrolls up.
*/
private startInitialScrollWatch(): void {
this.stopInitialScrollWatch(); // clean up any prior watcher
const el = this.messagesContainer?.nativeElement;
if (!el) return;
const snap = () => {
if (this.messagesContainer) {
const e = this.messagesContainer.nativeElement;
this.isAutoScrolling = true;
e.scrollTop = e.scrollHeight;
// Clear flag after browser fires the synchronous scroll event
requestAnimationFrame(() => { this.isAutoScrolling = false; });
}
};
// 1. MutationObserver catches new DOM nodes (attachments rendered, etc.)
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snap);
});
this.initialScrollObserver.observe(el, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src'], // img src swaps
});
// 2. Capture-phase 'load' listener catches images finishing load
this.boundOnImageLoad = () => requestAnimationFrame(snap);
el.addEventListener('load', this.boundOnImageLoad, true);
// 3. Auto-stop after 5s so we don't fight user scrolling
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
}
private stopInitialScrollWatch(): void {
if (this.initialScrollObserver) {
this.initialScrollObserver.disconnect();
this.initialScrollObserver = null;
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
this.boundOnImageLoad = null;
}
if (this.initialScrollTimer) {
clearTimeout(this.initialScrollTimer);
this.initialScrollTimer = null;
}
}
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());
});
}
/** Handle scroll events: toggle auto-scroll, dismiss snackbar, and trigger infinite scroll. */
onScroll(): void {
if (!this.messagesContainer) return;
// Ignore scroll events caused by programmatic snap-to-bottom
if (this.isAutoScrolling) 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);
}
// Any user-initiated scroll during the initial load period
// immediately hands control back to the user
if (this.initialScrollObserver) {
this.stopInitialScrollWatch();
}
// Infinite scroll upwards — load older messages when near the top
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore();
}
}
/** Load older messages by expanding the display window, preserving scroll position */
loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages()) return;
this.loadingMore.set(true);
const el = this.messagesContainer?.nativeElement;
const prevScrollHeight = el?.scrollHeight ?? 0;
this.displayLimit.update(limit => limit + this.PAGE_SIZE);
// After Angular renders the new messages, restore scroll position
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (el) {
const newScrollHeight = el.scrollHeight;
el.scrollTop += newScrollHeight - prevScrollHeight;
}
this.loadingMore.set(false);
});
});
}
// Markdown toolbar actions
/** Handle keyboard events in the message input (Enter to send, Shift+Enter for newline). */
onEnter(evt: Event): void {
const keyEvent = evt as KeyboardEvent;
if (keyEvent.shiftKey) {
// allow newline
return;
}
keyEvent.preventDefault();
this.sendMessage();
}
private getSelection(): { start: number; end: number } {
const el = this.messageInputRef?.nativeElement;
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
}
private setSelection(start: number, end: number): void {
const el = this.messageInputRef?.nativeElement;
if (el) {
el.selectionStart = start;
el.selectionEnd = end;
el.focus();
}
}
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */
applyInline(token: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
this.messageContent = newText;
const cursor = before.length + token.length + selected.length + token.length;
this.setSelection(cursor, cursor);
}
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
applyPrefix(prefix: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
}
/** Insert a markdown heading at the given level around the current selection. */
applyHeading(level: number): void {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'Heading';
const after = this.messageContent.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const newText = `${before}${block}${after}`;
this.messageContent = newText;
const cursor = before.length + block.length;
this.setSelection(cursor, cursor);
}
/** Convert selected lines into a numbered markdown list. */
applyOrderedList(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'item\nitem';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
}
/** Wrap the selection in a fenced markdown code block. */
applyCodeBlock(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'code';
const after = this.messageContent.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const newText = `${before}${fenced}${after}`;
this.messageContent = newText;
const cursor = before.length + fenced.length;
this.setSelection(cursor, cursor);
}
/** Insert a markdown link around the current selection. */
applyLink(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'link';
const after = this.messageContent.slice(end);
const link = `[${selected}](https://)`;
const newText = `${before}${link}${after}`;
this.messageContent = newText;
const cursorStart = before.length + link.length - 1; // position inside url
this.setSelection(cursorStart - 8, cursorStart - 1);
}
/** Insert a markdown image embed around the current selection. */
applyImage(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'alt';
const after = this.messageContent.slice(end);
const img = `![${selected}](https://)`;
const newText = `${before}${img}${after}`;
this.messageContent = newText;
const cursorStart = before.length + img.length - 1;
this.setSelection(cursorStart - 8, cursorStart - 1);
}
/** Insert a horizontal rule at the cursor position. */
applyHorizontalRule(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const after = this.messageContent.slice(end);
const hr = `\n\n---\n\n`;
const newText = `${before}${hr}${after}`;
this.messageContent = newText;
const cursor = before.length + hr.length;
this.setSelection(cursor, cursor);
}
/** Handle drag-enter to activate the drop zone overlay. */
// Attachments: drag/drop and rendering
onDragEnter(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(true);
}
/** Keep the drop zone active while dragging over. */
onDragOver(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(true);
}
/** Deactivate the drop zone when dragging leaves. */
onDragLeave(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(false);
}
/** Handle dropped files, adding them to the pending upload queue. */
onDrop(evt: DragEvent): void {
evt.preventDefault();
const files: File[] = [];
const items = evt.dataTransfer?.items;
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
}
}
} else if (evt.dataTransfer?.files?.length) {
for (let i = 0; i < evt.dataTransfer.files.length; i++) {
files.push(evt.dataTransfer.files[i]);
}
}
files.forEach((file) => this.pendingFiles.push(file));
// Keep toolbar visible so user sees options
this.toolbarVisible.set(true);
this.dragActive.set(false);
}
/** Return all file attachments associated with a message. */
getAttachments(messageId: string): Attachment[] {
return this.attachmentsSvc.getForMessage(messageId);
}
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let i = 0;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(1)} ${units[i]}`;
}
/** Format a transfer speed in bytes/second to a human-readable string. */
formatSpeed(bps?: number): string {
if (!bps || bps <= 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let speed = bps;
let i = 0;
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
}
/** Remove a pending file from the upload queue. */
removePendingFile(file: File): void {
const idx = this.pendingFiles.findIndex((pending) => pending === file);
if (idx >= 0) {
this.pendingFiles.splice(idx, 1);
}
}
/** Download a completed attachment to the user's device. */
downloadAttachment(att: Attachment): void {
if (!att.available || !att.objectUrl) return;
const a = document.createElement('a');
a.href = att.objectUrl;
a.download = att.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/** Request a file attachment to be transferred from the uploader peer. */
requestAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestFile(messageId, att);
}
/** Cancel an in-progress attachment transfer request. */
cancelAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.cancelRequest(messageId, att);
}
/** Check whether the current user is the original uploader of an attachment. */
isUploader(att: Attachment): boolean {
const myUserId = this.currentUser()?.id;
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
}
/** Open the image lightbox for a completed image attachment. */
// ---- Image lightbox ----
openLightbox(att: Attachment): void {
if (att.available && att.objectUrl) {
this.lightboxAttachment.set(att);
}
}
/** Close the image lightbox. */
closeLightbox(): void {
this.lightboxAttachment.set(null);
}
/** Open a context menu on right-click of an image attachment. */
// ---- Image context menu ----
openImageContextMenu(event: MouseEvent, att: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
}
/** Close the image context menu. */
closeImageContextMenu(): void {
this.imageContextMenu.set(null);
}
/** Copy an image attachment to the system clipboard as PNG. */
async copyImageToClipboard(att: Attachment): Promise<void> {
this.closeImageContextMenu();
if (!att.objectUrl) return;
try {
const resp = await fetch(att.objectUrl);
const blob = await resp.blob();
// Convert to PNG for clipboard compatibility
const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob }),
]);
} catch (_error) {
// Failed to copy image to clipboard
}
}
private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
if (blob.type === 'image/png') {
resolve(blob);
return;
}
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) { reject(new Error('Canvas not supported')); return; }
ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => {
URL.revokeObjectURL(url);
if (pngBlob) resolve(pngBlob);
else reject(new Error('PNG conversion failed'));
}, 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
img.src = url;
});
}
/** Retry fetching an image from any available peer. */
retryImageRequest(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestImageFromAnyPeer(messageId, att);
}
private attachFilesToLastOwnMessage(content: string): void {
const me = this.currentUser()?.id;
if (!me) return;
const msg = [...this.messages()].reverse().find((message) => message.senderId === me && message.content === content && !message.isDeleted);
if (!msg) {
// Retry shortly until message appears
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
return;
}
const uploaderPeerId = this.currentUser()?.id || undefined;
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
this.pendingFiles = [];
}
// Detect image URLs and append Markdown embeds at the end
private appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0) return content;
let append = '';
for (const url of urls) {
// Skip if already embedded as a Markdown image
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\\\]\\(\s*${this.escapeRegex(url)}\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
autoResizeTextarea(): void {
const el = this.messageInputRef?.nativeElement;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 520) + 'px';
el.style.overflowY = el.scrollHeight > 520 ? 'auto' : 'hidden';
this.updateScrollPadding();
}
/** Keep scroll container bottom-padding in sync with the floating bottom bar height. */
private updateScrollPadding(): void {
requestAnimationFrame(() => {
const bar = this.bottomBar?.nativeElement;
const scroll = this.messagesContainer?.nativeElement;
if (!bar || !scroll) return;
scroll.style.paddingBottom = bar.offsetHeight + 20 + 'px';
});
}
/** Show the markdown toolbar when the input gains focus. */
onInputFocus(): void {
this.toolbarVisible.set(true);
}
/** Hide the markdown toolbar after a brief delay when the input loses focus. */
onInputBlur(): void {
setTimeout(() => {
if (!this.toolbarHovering) {
this.toolbarVisible.set(false);
}
}, 150);
}
/** Track mouse entry on the toolbar to prevent premature hiding. */
onToolbarMouseEnter(): void {
this.toolbarHovering = true;
}
/** Track mouse leave on the toolbar; hide if input is not focused. */
onToolbarMouseLeave(): void {
this.toolbarHovering = false;
if (document.activeElement !== this.messageInputRef?.nativeElement) {
this.toolbarVisible.set(false);
}
}
/** Handle Ctrl key down for enabling manual resize. */
onDocKeydown(event: KeyboardEvent): void {
if (event.key === 'Control') this.ctrlHeld.set(true);
}
/** Handle Ctrl key up for disabling manual resize. */
onDocKeyup(event: KeyboardEvent): void {
if (event.key === 'Control') this.ctrlHeld.set(false);
}
/** Scroll to the newest message and dismiss the new-messages snackbar. */
readLatest(): void {
this.shouldScrollToBottom = true;
this.scrollToBottomSmooth();
this.showNewMessagesBar.set(false);
}
}

View File

@@ -0,0 +1,12 @@
@if (typingDisplay().length > 0) {
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
{{ typingDisplay().join(', ') }}
@if (typingOthersCount() > 0) {
and {{ typingOthersCount() }} others are typing...
} @else {
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
}
</span>
</div>
}

View File

@@ -0,0 +1,67 @@
import { Component, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { merge, interval, filter, map, tap } from 'rxjs';
const TYPING_TTL = 3_000;
const PURGE_INTERVAL = 1_000;
const MAX_SHOWN = 4;
@Component({
selector: 'app-typing-indicator',
standalone: true,
templateUrl: './typing-indicator.component.html',
host: {
'class': 'block',
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));',
},
})
export class TypingIndicatorComponent {
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
constructor() {
const webrtc = inject(WebRTCService);
const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
tap((msg: any) => {
const now = Date.now();
this.typingMap.set(String(msg.oderId), {
name: String(msg.displayName),
expiresAt: now + TYPING_TTL,
});
}),
);
const purge$ = interval(PURGE_INTERVAL).pipe(
map(() => Date.now()),
filter((now) => {
let changed = false;
for (const [key, entry] of this.typingMap) {
if (entry.expiresAt <= now) {
this.typingMap.delete(key);
changed = true;
}
}
return changed;
}),
);
merge(typing$, purge$)
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => this.recomputeDisplay());
}
private recomputeDisplay(): void {
const now = Date.now();
const names = Array.from(this.typingMap.values())
.filter((e) => e.expiresAt > now)
.map((e) => e.name);
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
}
}

View File

@@ -1,276 +0,0 @@
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,147 @@
<!-- 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">
<app-user-avatar [name]="user.displayName" size="sm" />
<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>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="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>
<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>
</app-confirm-dialog>
}

View File

@@ -0,0 +1,124 @@
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 { UsersActions } from '../../../store/users/users.actions';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin,
} from '../../../store/users/users.selectors';
import { User } from '../../../core/models';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
}),
],
templateUrl: './user-list.component.html',
})
/**
* Displays the list of online users with voice state indicators and admin actions.
*/
export class UserListComponent {
private store = inject(Store);
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
showUserMenu = signal<string | null>(null);
showBanDialog = signal(false);
userToBan = signal<User | null>(null);
banReason = '';
banDuration = '86400000'; // Default 1 day
/** Toggle the context menu for a specific user. */
toggleUserMenu(userId: string): void {
this.showUserMenu.update((current) => (current === userId ? null : userId));
}
/** Check whether the given user is the currently authenticated user. */
isCurrentUser(user: User): boolean {
return user.id === this.currentUser()?.id;
}
/** Toggle server-side mute on a user (admin action). */
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);
}
/** Kick a user from the server (admin action). */
kickUser(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.showUserMenu.set(null);
}
/** Open the ban confirmation dialog for a user (admin action). */
banUser(user: User): void {
this.userToBan.set(user);
this.showBanDialog.set(true);
this.showUserMenu.set(null);
}
/** Close the ban dialog and reset its form fields. */
closeBanDialog(): void {
this.showBanDialog.set(false);
this.userToBan.set(null);
this.banReason = '';
this.banDuration = '86400000';
}
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
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

@@ -12,8 +12,8 @@ import {
lucideChevronLeft,
} from '@ng-icons/lucide';
import { ChatMessagesComponent } from '../../chat/chat-messages.component';
import { UserListComponent } from '../../chat/user-list.component';
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
import { UserListComponent } from '../../chat/user-list/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';
@@ -46,6 +46,9 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
],
templateUrl: './chat-room.component.html',
})
/**
* Main chat room view combining the messages panel, side panels, and admin controls.
*/
export class ChatRoomComponent {
private store = inject(Store);
private router = inject(Router);
@@ -57,13 +60,15 @@ export class ChatRoomComponent {
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
/** Returns the display name of the currently active text channel. */
get activeChannelName(): string {
const id = this.activeChannelId();
const ch = this.textChannels().find(c => c.id === id);
return ch ? ch.name : id;
const activeChannel = this.textChannels().find(channel => channel.id === id);
return activeChannel ? activeChannel.name : id;
}
/** Toggle the admin panel sidebar visibility. */
toggleAdminPanel() {
this.showAdminPanel.update(v => !v);
this.showAdminPanel.update((current) => !current);
}
}

View File

@@ -126,25 +126,12 @@
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom(ch.id); track u.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
@if (u.avatarUrl) {
<img
[src]="u.avatarUrl"
alt=""
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
/>
} @else {
<div
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
>
{{ u.displayName.charAt(0).toUpperCase() }}
</div>
}
<app-user-avatar
[name]="u.displayName"
[avatarUrl]="u.avatarUrl"
size="xs"
[ringClass]="u.voiceState?.isDeafened ? 'ring-2 ring-red-500' : u.voiceState?.isMuted ? 'ring-2 ring-yellow-500' : 'ring-2 ring-green-500'"
/>
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
<button
@@ -177,13 +164,7 @@
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
<div class="relative">
@if (currentUser()?.avatarUrl) {
<img [src]="currentUser()?.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
} @else {
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div>
}
<app-user-avatar [name]="currentUser()?.displayName || '?'" [avatarUrl]="currentUser()?.avatarUrl" size="sm" />
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
@@ -220,13 +201,7 @@
(contextmenu)="openUserContextMenu($event, user)"
>
<div class="relative">
@if (user.avatarUrl) {
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
} @else {
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
{{ user.displayName.charAt(0).toUpperCase() }}
</div>
}
<app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="sm" />
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
@@ -283,77 +258,53 @@
<!-- Channel context menu -->
@if (showChannelMenu()) {
<div class="fixed inset-0 z-40" (click)="closeChannelMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-44 py-1" [style.left.px]="channelMenuX()" [style.top.px]="channelMenuY()">
<button (click)="resyncMessages()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Resync Messages
</button>
<app-context-menu [x]="channelMenuX()" [y]="channelMenuY()" (closed)="closeChannelMenu()" [width]="'w-44'">
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button>
@if (canManageChannels()) {
<div class="border-t border-border my-1"></div>
<button (click)="startRename()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Rename Channel
</button>
<button (click)="deleteChannel()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
Delete Channel
</button>
<div class="context-menu-divider"></div>
<button (click)="startRename()" class="context-menu-item">Rename Channel</button>
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button>
}
</div>
</app-context-menu>
}
<!-- User context menu (kick / role management) -->
@if (showUserMenu()) {
<div class="fixed inset-0 z-40" (click)="closeUserMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-48 py-1" [style.left.px]="userMenuX()" [style.top.px]="userMenuY()">
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
@if (isAdmin()) {
<!-- Role management -->
@if (contextMenuUser()?.role === 'member') {
<button (click)="changeUserRole('moderator')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Moderator
</button>
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Admin
</button>
<button (click)="changeUserRole('moderator')" class="context-menu-item">Promote to Moderator</button>
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
}
@if (contextMenuUser()?.role === 'moderator') {
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Admin
</button>
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Demote to Member
</button>
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
}
@if (contextMenuUser()?.role === 'admin') {
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Demote to Member
</button>
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
}
<div class="border-t border-border my-1"></div>
<button (click)="kickUserAction()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
Kick User
</button>
<div class="context-menu-divider"></div>
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button>
} @else {
<div class="px-3 py-1.5 text-sm text-muted-foreground">No actions available</div>
<div class="context-menu-empty">No actions available</div>
}
</div>
</app-context-menu>
}
<!-- Create channel dialog -->
@if (showCreateChannelDialog()) {
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelCreateChannel()"></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-[320px]">
<div class="p-4">
<h4 class="font-semibold text-foreground mb-3">Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel</h4>
<input
type="text"
[(ngModel)]="newChannelName"
placeholder="Channel name"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
(keydown.enter)="confirmCreateChannel()"
/>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button (click)="cancelCreateChannel()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm">Cancel</button>
<button (click)="confirmCreateChannel()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm">Create</button>
</div>
</div>
<app-confirm-dialog
[title]="'Create ' + (createChannelType() === 'text' ? 'Text' : 'Voice') + ' Channel'"
confirmLabel="Create"
(confirmed)="confirmCreateChannel()"
(cancelled)="cancelCreateChannel()"
>
<input
type="text"
[(ngModel)]="newChannelName"
placeholder="Channel name"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
(keydown.enter)="confirmCreateChannel()"
/>
</app-confirm-dialog>
}

View File

@@ -6,12 +6,13 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus } from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors';
import * as UsersActions from '../../../store/users/users.actions';
import * as RoomsActions from '../../../store/rooms/rooms.actions';
import * as MessagesActions from '../../../store/messages/messages.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.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';
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
import { Channel, User } from '../../../core/models';
import { v4 as uuidv4 } from 'uuid';
@@ -20,12 +21,15 @@ type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent],
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
viewProviders: [
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
],
templateUrl: './rooms-side-panel.component.html',
})
/**
* Side panel listing text and voice channels, online users, and channel management actions.
*/
export class RoomsSidePanelComponent {
private store = inject(Store);
private webrtc = inject(WebRTCService);
@@ -61,14 +65,16 @@ export class RoomsSidePanelComponent {
userMenuY = signal(0);
contextMenuUser = signal<User | null>(null);
/** Return online users excluding the current user. */
// 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);
return this.onlineUsers().filter(user => user.id !== currentId && user.oderId !== currentOderId);
}
/** Check whether the current user has permission to manage channels. */
canManageChannels(): boolean {
const room = this.currentRoom();
const user = this.currentUser();
@@ -81,12 +87,14 @@ export class RoomsSidePanelComponent {
return false;
}
/** Select a text channel (no-op if currently renaming). */
// ---- Text channel selection ----
selectTextChannel(channelId: string) {
if (this.renamingChannelId()) return; // don't switch while renaming
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
}
/** Open the context menu for a channel at the cursor position. */
// ---- Channel context menu ----
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
evt.preventDefault();
@@ -96,10 +104,12 @@ export class RoomsSidePanelComponent {
this.showChannelMenu.set(true);
}
/** Close the channel context menu. */
closeChannelMenu() {
this.showChannelMenu.set(false);
}
/** Begin inline renaming of the context-menu channel. */
startRename() {
const ch = this.contextChannel();
this.closeChannelMenu();
@@ -108,6 +118,7 @@ export class RoomsSidePanelComponent {
}
}
/** Commit the channel rename from the inline input value. */
confirmRename(event: Event) {
const input = event.target as HTMLInputElement;
const name = input.value.trim();
@@ -118,10 +129,12 @@ export class RoomsSidePanelComponent {
this.renamingChannelId.set(null);
}
/** Cancel the inline rename operation. */
cancelRename() {
this.renamingChannelId.set(null);
}
/** Delete the context-menu channel. */
deleteChannel() {
const ch = this.contextChannel();
this.closeChannelMenu();
@@ -130,11 +143,11 @@ export class RoomsSidePanelComponent {
}
}
/** Trigger a message inventory re-sync from all connected peers. */
resyncMessages() {
this.closeChannelMenu();
const room = this.currentRoom();
if (!room) {
console.warn('[Resync] No current room');
return;
}
@@ -143,19 +156,19 @@ export class RoomsSidePanelComponent {
// Request inventory from all connected peers
const peers = this.webrtc.getConnectedPeers();
console.log(`[Resync] Requesting inventory from ${peers.length} peer(s) for room ${room.id}`);
if (peers.length === 0) {
console.warn('[Resync] No connected peers — sync will time out');
// No connected peers — sync will time out
}
peers.forEach((pid) => {
try {
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
} catch (e) {
console.error(`[Resync] Failed to send to peer ${pid}:`, e);
} catch (_error) {
// Failed to send inventory request to this peer
}
});
}
/** Open the create-channel dialog for the given channel type. */
// ---- Create channel ----
createChannel(type: 'text' | 'voice') {
this.createChannelType.set(type);
@@ -163,6 +176,7 @@ export class RoomsSidePanelComponent {
this.showCreateChannelDialog.set(true);
}
/** Confirm channel creation and dispatch the add-channel action. */
confirmCreateChannel() {
const name = this.newChannelName.trim();
if (!name) return;
@@ -178,10 +192,12 @@ export class RoomsSidePanelComponent {
this.showCreateChannelDialog.set(false);
}
/** Cancel channel creation and close the dialog. */
cancelCreateChannel() {
this.showCreateChannelDialog.set(false);
}
/** Open the user context menu for admin actions (kick/role change). */
// ---- User context menu (kick/role) ----
openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
@@ -192,10 +208,12 @@ export class RoomsSidePanelComponent {
this.showUserMenu.set(true);
}
/** Close the user context menu. */
closeUserMenu() {
this.showUserMenu.set(false);
}
/** Change a user's role and broadcast the update to connected peers. */
changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser();
this.closeUserMenu();
@@ -210,6 +228,7 @@ export class RoomsSidePanelComponent {
}
}
/** Kick a user and broadcast the action to peers. */
kickUserAction() {
const user = this.contextMenuUser();
this.closeUserMenu();
@@ -224,12 +243,13 @@ export class RoomsSidePanelComponent {
}
}
/** Join a voice channel, managing permissions and existing voice connections. */
// ---- Voice ----
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');
// Voice is disabled by room permissions
return;
}
@@ -248,7 +268,7 @@ export class RoomsSidePanelComponent {
}));
}
} else {
console.warn('Already connected to voice in another server. Disconnect first before joining.');
// Already connected to voice in another server; must disconnect first
return;
}
}
@@ -280,8 +300,8 @@ export class RoomsSidePanelComponent {
// Update voice session for floating controls
if (room) {
// Find label from channel list
const vc = this.voiceChannels().find(c => c.id === roomId);
const voiceRoomName = vc ? `🔊 ${vc.name}` : roomId;
const voiceChannel = this.voiceChannels().find(channel => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
@@ -292,9 +312,12 @@ export class RoomsSidePanelComponent {
serverRoute: `/room/${room.id}`,
});
}
}).catch((e) => console.error('Failed to join voice room', roomId, e));
}).catch((_error) => {
// Failed to join voice room
});
}
/** Leave a voice channel and broadcast the disconnect state. */
leaveVoice(roomId: string) {
const current = this.currentUser();
// Only leave if currently in this room
@@ -326,32 +349,36 @@ export class RoomsSidePanelComponent {
this.voiceSessionService.endSession();
}
/** Count the number of users connected to a voice channel in the current room. */
voiceOccupancy(roomId: string): number {
const users = this.onlineUsers();
const room = this.currentRoom();
return users.filter(u =>
!!u.voiceState?.isConnected &&
u.voiceState?.roomId === roomId &&
u.voiceState?.serverId === room?.id
return users.filter(user =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
).length;
}
/** Dispatch a viewer:focus event to display a remote user's screen share. */
viewShare(userId: string) {
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt);
}
/** Dispatch a viewer:focus event to display a remote user's stream. */
viewStream(userId: string) {
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt);
}
/** Check whether a user is currently sharing their screen. */
isUserSharing(userId: string): boolean {
const me = this.currentUser();
if (me?.id === userId) {
return this.webrtc.isScreenSharing();
}
const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
const user = this.onlineUsers().find(onlineUser => onlineUser.id === userId || onlineUser.oderId === userId);
if (user?.screenShareState?.isSharing === false) {
return false;
}
@@ -359,15 +386,17 @@ export class RoomsSidePanelComponent {
return !!stream && stream.getVideoTracks().length > 0;
}
/** Return all users currently connected to a specific voice channel. */
voiceUsersInRoom(roomId: string) {
const room = this.currentRoom();
return this.onlineUsers().filter(u =>
!!u.voiceState?.isConnected &&
u.voiceState?.roomId === roomId &&
u.voiceState?.serverId === room?.id
return this.onlineUsers().filter(user =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
);
}
/** Check whether the current user is connected to the specified voice channel. */
isCurrentRoom(roomId: string): boolean {
const me = this.currentUser();
const room = this.currentRoom();
@@ -378,6 +407,7 @@ export class RoomsSidePanelComponent {
);
}
/** Check whether voice is enabled by the current room's permissions. */
voiceEnabled(): boolean {
const room = this.currentRoom();
return room?.permissions?.allowVoice !== false;

View File

@@ -14,7 +14,7 @@ import {
lucideSettings,
} from '@ng-icons/lucide';
import * as RoomsActions from '../../store/rooms/rooms.actions';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import {
selectSearchResults,
selectIsSearching,
@@ -33,6 +33,10 @@ import { ServerInfo } from '../../core/models';
],
templateUrl: './server-search.component.html',
})
/**
* Server search and discovery view with server creation dialog.
* Allows users to search for, join, and create new servers.
*/
export class ServerSearchComponent implements OnInit {
private store = inject(Store);
private router = inject(Router);
@@ -52,6 +56,7 @@ export class ServerSearchComponent implements OnInit {
newServerPrivate = signal(false);
newServerPassword = signal('');
/** Initialize server search, load saved rooms, and set up debounced search. */
ngOnInit(): void {
// Initial load
this.store.dispatch(RoomsActions.searchServers({ query: '' }));
@@ -65,10 +70,12 @@ export class ServerSearchComponent implements OnInit {
});
}
/** Emit a search query to the debounced search subject. */
onSearchChange(query: string): void {
this.searchSubject.next(query);
}
/** Join a server from the search results. Redirects to login if unauthenticated. */
joinServer(server: ServerInfo): void {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
@@ -85,15 +92,18 @@ export class ServerSearchComponent implements OnInit {
}));
}
/** Open the create-server dialog. */
openCreateDialog(): void {
this.showCreateDialog.set(true);
}
/** Close the create-server dialog and reset the form. */
closeCreateDialog(): void {
this.showCreateDialog.set(false);
this.resetCreateForm();
}
/** Submit the new server creation form and dispatch the create action. */
createServer(): void {
if (!this.newServerName()) return;
const currentUserId = localStorage.getItem('metoyou_currentUserId');
@@ -115,10 +125,12 @@ export class ServerSearchComponent implements OnInit {
this.closeCreateDialog();
}
/** Navigate to the application settings page. */
openSettings(): void {
this.router.navigate(['/settings']);
}
/** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void {
this.joinServer({
id: room.id,

View File

@@ -10,48 +10,44 @@
<!-- 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">
@for (room of savedRooms(); track room.id) {
<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">
@if (room.icon) {
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
</ng-container>
<ng-template #noIcon>
} @else {
<div class="w-full h-full flex items-center justify-center bg-secondary">
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
</div>
</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>
@if (showMenu()) {
<app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-44'">
@if (isCurrentContextRoom()) {
<button (click)="leaveServer()" class="context-menu-item">Leave Server</button>
}
<button (click)="openForgetConfirm()" class="context-menu-item">Forget Server</button>
</app-context-menu>
}
<!-- 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>
@if (showConfirm()) {
<app-confirm-dialog
title="Forget Server?"
confirmLabel="Forget"
(confirmed)="confirmForget()"
(cancelled)="cancelForget()"
[widthClass]="'w-[280px]'"
>
<p>Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.</p>
</app-confirm-dialog>
}

View File

@@ -9,15 +9,19 @@ import { Room } from '../../core/models';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import * as RoomsActions from '../../store/rooms/rooms.actions';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
@Component({
selector: 'app-servers-rail',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html',
})
/**
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
*/
export class ServersRailComponent {
private store = inject(Store);
private router = inject(Router);
@@ -28,12 +32,13 @@ export class ServersRailComponent {
// Context menu state
showMenu = signal(false);
menuX = signal(72); // rail width (~64px) + padding, position menu to the right
menuY = signal(100);
menuX = signal(72); // default X: rail width (~64px) + padding
menuY = signal(100); // default Y: arbitrary initial offset
contextRoom = signal<Room | null>(null);
// Confirmation dialog state
showConfirm = signal(false);
/** Return the first character of a server name as its icon initial. */
initial(name?: string): string {
if (!name) return '?';
const ch = name.trim()[0]?.toUpperCase();
@@ -42,6 +47,7 @@ export class ServersRailComponent {
trackRoomId = (index: number, room: Room) => room.id;
/** Navigate to the server search view. Updates voice session state if applicable. */
createServer(): void {
// Navigate to server list (has create button)
// Update voice session state if connected to voice
@@ -52,6 +58,7 @@ export class ServersRailComponent {
this.router.navigate(['/search']);
}
/** Join or switch to a saved room. Manages voice session and authentication state. */
joinSavedRoom(room: Room): void {
// Require auth: if no current user, go to login
const currentUserId = localStorage.getItem('metoyou_currentUserId');
@@ -88,37 +95,43 @@ export class ServersRailComponent {
}
}
/** Open the context menu positioned near the cursor for a given room. */
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
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
this.menuX.set(Math.max((evt.clientX + 8), 72));
this.menuY.set(evt.clientY);
this.showMenu.set(true);
}
/** Close the context menu (keeps contextRoom for potential confirmation). */
closeMenu(): void {
this.showMenu.set(false);
// keep contextRoom for potential confirmation dialog
}
/** Check whether the context-menu room is the currently active room. */
isCurrentContextRoom(): boolean {
const ctx = this.contextRoom();
const cur = this.currentRoom();
return !!ctx && !!cur && ctx.id === cur.id;
}
/** Leave the current server and navigate to the servers list. */
leaveServer(): void {
this.closeMenu();
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
}
/** Show the forget-server confirmation dialog. */
openForgetConfirm(): void {
this.showConfirm.set(true);
this.closeMenu();
}
/** Forget (remove) a server from the saved list, leaving if it is the current room. */
confirmForget(): void {
const ctx = this.contextRoom();
if (!ctx) return;
@@ -131,6 +144,7 @@ export class ServersRailComponent {
this.contextRoom.set(null);
}
/** Cancel the forget-server confirmation dialog. */
cancelForget(): void {
this.showConfirm.set(false);
}

View File

@@ -16,6 +16,7 @@ import {
} from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../core/constants';
@Component({
selector: 'app-settings',
@@ -36,6 +37,9 @@ import { ServerDirectoryService } from '../../core/services/server-directory.ser
],
templateUrl: './settings.component.html',
})
/**
* Settings page for managing signaling servers and connection preferences.
*/
export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryService);
private router = inject(Router);
@@ -49,10 +53,12 @@ export class SettingsComponent implements OnInit {
autoReconnect = true;
searchAllServers = true;
/** Load persisted connection settings on component init. */
ngOnInit(): void {
this.loadConnectionSettings();
}
/** Add a new signaling server after URL validation and duplicate checking. */
addServer(): void {
this.addError.set(null);
@@ -65,7 +71,7 @@ export class SettingsComponent implements OnInit {
}
// Check for duplicates
if (this.servers().some((s) => s.url === this.newServerUrl)) {
if (this.servers().some((server) => server.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
return;
}
@@ -87,22 +93,26 @@ export class SettingsComponent implements OnInit {
}
}
/** Remove a signaling server by its ID. */
removeServer(id: string): void {
this.serverDirectory.removeServer(id);
}
/** Set the active signaling server used for connections. */
setActiveServer(id: string): void {
this.serverDirectory.setActiveServer(id);
}
/** Test connectivity to all configured servers. */
async testAllServers(): Promise<void> {
this.isTesting.set(true);
await this.serverDirectory.testAllServers();
this.isTesting.set(false);
}
/** Load connection settings (auto-reconnect, search scope) from localStorage. */
loadConnectionSettings(): void {
const settings = localStorage.getItem('metoyou_connection_settings');
const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (settings) {
const parsed = JSON.parse(settings);
this.autoReconnect = parsed.autoReconnect ?? true;
@@ -111,9 +121,10 @@ export class SettingsComponent implements OnInit {
}
}
/** Persist current connection settings to localStorage. */
saveConnectionSettings(): void {
localStorage.setItem(
'metoyou_connection_settings',
STORAGE_KEY_CONNECTION_SETTINGS,
JSON.stringify({
autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers,
@@ -122,6 +133,7 @@ export class SettingsComponent implements OnInit {
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
/** Navigate back to the main page. */
goBack(): void {
this.router.navigate(['/']);
}

View File

@@ -1,41 +1,75 @@
<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
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;">
@if (inRoom()) {
<button (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
</button>
}
@if (inRoom()) {
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
@if (roomDescription()) {
<span 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 -->
@if (showMenu()) {
<div 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>
}
} @else {
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
@if (isReconnecting()) {
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
}
</div>
}
</div>
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
@if (!isAuthed()) {
<button
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
(click)="goLogin()"
title="Login"
>
Login
</button>
}
@if (isElectron()) {
<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>
@if (showMenu()) {
<div class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
}

View File

@@ -5,10 +5,12 @@ 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 { 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';
import { PlatformService } from '../../core/services/platform.service';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
@Component({
selector: 'app-title-bar',
@@ -17,17 +19,24 @@ import { WebRTCService } from '../../core/services/webrtc.service';
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
templateUrl: './title-bar.component.html',
})
/**
* Electron-style title bar with window controls, navigation, and server menu.
*/
export class TitleBarComponent {
private store = inject(Store);
private serverDirectory = inject(ServerDirectoryService);
private router = inject(Router);
private webrtc = inject(WebRTCService);
private platform = inject(PlatformService);
isElectron = computed(() => this.platform.isElectron);
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());
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
isAuthed = computed(() => !!this.currentUserSig());
private currentRoomSig = this.store.selectSignal(selectCurrentRoom);
inRoom = computed(() => !!this.currentRoomSig());
@@ -36,52 +45,61 @@ export class TitleBarComponent {
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu());
/** Minimize the Electron window. */
minimize() {
const api = (window as any).electronAPI;
if (api?.minimizeWindow) api.minimizeWindow();
}
/** Maximize or restore the Electron window. */
maximize() {
const api = (window as any).electronAPI;
if (api?.maximizeWindow) api.maximizeWindow();
}
/** Close the Electron window. */
close() {
const api = (window as any).electronAPI;
if (api?.closeWindow) api.closeWindow();
}
/** Navigate to the login page. */
goLogin() {
this.router.navigate(['/login']);
}
/** Leave the current room and navigate back to the server search. */
onBack() {
// Leave room to ensure header switches to user/server view
this.store.dispatch(RoomsActions.leaveRoom());
this.router.navigate(['/search']);
}
/** Toggle the server dropdown menu. */
toggleMenu() {
this._showMenu.set(!this._showMenu());
}
/** Leave the current server and navigate to the servers list. */
leaveServer() {
this._showMenu.set(false);
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
}
/** Close the server dropdown menu. */
closeMenu() {
this._showMenu.set(false);
}
/** Log out the current user, disconnect from signaling, and navigate to login. */
logout() {
this._showMenu.set(false);
// Disconnect from signaling server this broadcasts "user_left" to all
// servers the user was a member of, so other users see them go offline.
this.webrtc.disconnect();
try {
localStorage.removeItem('metoyou_currentUserId');
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
} catch {}
this.router.navigate(['/login']);
}

View File

@@ -15,7 +15,7 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import * as UsersActions from '../../../store/users/users.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
@Component({
@@ -34,6 +34,10 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
],
templateUrl: './floating-voice-controls.component.html'
})
/**
* Floating voice controls displayed when the user navigates away from the voice-connected server.
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
*/
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
@@ -52,6 +56,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
private stateSubscription: Subscription | null = null;
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
ngOnInit(): void {
// Sync mute/deafen state from webrtc service
this.isMuted.set(this.webrtcService.isMuted());
@@ -63,12 +68,14 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
this.stateSubscription?.unsubscribe();
}
/** Navigate back to the voice-connected server. */
navigateToServer(): void {
this.voiceSessionService.navigateToVoiceServer();
}
/** Toggle microphone mute and broadcast the updated voice state. */
toggleMute(): void {
this.isMuted.update(v => !v);
this.isMuted.update((current) => !current);
this.webrtcService.toggleMute(this.isMuted());
// Broadcast mute state change
@@ -84,8 +91,9 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
});
}
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
toggleDeafen(): void {
this.isDeafened.update(v => !v);
this.isDeafened.update((current) => !current);
this.webrtcService.toggleDeafen(this.isDeafened());
// When deafening, also mute
@@ -107,6 +115,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
});
}
/** Toggle screen sharing on or off. */
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare();
@@ -115,12 +124,13 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
try {
await this.webrtcService.startScreenShare(false);
this.isScreenSharing.set(true);
} catch (error) {
console.error('Failed to start screen share:', error);
} catch (_error) {
// Screen share request was denied or failed
}
}
}
/** Disconnect from the voice session entirely, cleaning up all voice state. */
disconnect(): void {
// Stop voice heartbeat
this.webrtcService.stopVoiceHeartbeat();
@@ -163,6 +173,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
this.isDeafened.set(false);
}
/** Return the CSS classes for the compact control button based on active state. */
getCompactButtonClass(isActive: boolean): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (isActive) {
@@ -171,6 +182,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the compact screen-share button. */
getCompactScreenShareClass(): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (this.isScreenSharing()) {
@@ -179,6 +191,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the mute toggle button. */
getMuteButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isMuted()) {
@@ -187,6 +200,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the deafen toggle button. */
getDeafenButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isDeafened()) {
@@ -195,6 +209,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the screen-share toggle button. */
getScreenShareButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isScreenSharing()) {

View File

@@ -19,12 +19,13 @@
<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>
@if (activeScreenSharer()) {
<span class="text-sm font-medium">
{{ activeScreenSharer()?.displayName }} is sharing their screen
</span>
} @else {
<span class="text-sm font-medium">Someone is sharing their screen</span>
</ng-template>
}
</div>
<div class="flex items-center gap-3">
<!-- Viewer volume -->
@@ -71,10 +72,12 @@
</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>
@if (!hasStream()) {
<div 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>
}
</div>

View File

@@ -13,6 +13,7 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service';
import { selectOnlineUsers } from '../../../store/users/users.selectors';
import { User } from '../../../core/models';
import { DEFAULT_VOLUME } from '../../../core/constants';
@Component({
selector: 'app-screen-share-viewer',
@@ -28,6 +29,10 @@ import { User } from '../../../core/models';
],
templateUrl: './screen-share-viewer.component.html',
})
/**
* Displays a local or remote screen-share stream in a video player.
* Supports fullscreen toggling, volume control, and viewer focus events.
*/
export class ScreenShareViewerComponent implements OnDestroy {
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
@@ -43,7 +48,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
isFullscreen = signal(false);
hasStream = signal(false);
isLocalShare = signal(false);
screenVolume = signal(100);
screenVolume = signal(DEFAULT_VOLUME);
private streamSubscription: (() => void) | null = null;
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
@@ -51,7 +56,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
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;
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
if (stream && stream.getVideoTracks().length > 0) {
if (user) {
this.setRemoteStream(stream, user);
@@ -63,8 +68,8 @@ export class ScreenShareViewerComponent implements OnDestroy {
this.isLocalShare.set(false);
}
}
} catch (e) {
console.error('Failed to focus viewer on user stream:', e);
} catch (_error) {
// Failed to focus viewer on user stream
}
};
@@ -95,7 +100,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
if (!watchingId || !isWatchingRemote) return;
const users = this.onlineUsers();
const watchedUser = users.find(u => u.id === watchingId || u.oderId === watchingId);
const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId);
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
@@ -105,7 +110,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
// 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');
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
if (!hasActiveVideo) {
// Stream or video tracks are gone - stop watching
this.stopWatching();
@@ -136,6 +141,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
}
/** Toggle between fullscreen and windowed display. */
toggleFullscreen(): void {
if (this.isFullscreen()) {
this.exitFullscreen();
@@ -144,6 +150,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Enter fullscreen mode, requesting browser fullscreen if available. */
enterFullscreen(): void {
this.isFullscreen.set(true);
// Request browser fullscreen if available
@@ -154,6 +161,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Exit fullscreen mode. */
exitFullscreen(): void {
this.isFullscreen.set(false);
if (document.fullscreenElement) {
@@ -161,6 +169,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Stop the local screen share and reset viewer state. */
stopSharing(): void {
this.webrtcService.stopScreenShare();
this.activeScreenSharer.set(null);
@@ -168,6 +177,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
this.isLocalShare.set(false);
}
/** Stop watching a remote stream and reset the viewer. */
// Stop watching a remote stream (for viewers)
stopWatching(): void {
if (this.videoRef) {
@@ -182,6 +192,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Attach and play a remote peer's screen-share stream. */
// Called by parent when a remote peer starts sharing
setRemoteStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
@@ -212,6 +223,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Attach and play the local user's screen-share stream (always muted). */
// Called when local user starts sharing
setLocalStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
@@ -225,6 +237,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Handle volume slider changes, applying only to remote streams. */
onScreenVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
const val = Math.max(0, Math.min(100, parseInt(input.value, 10)));

View File

@@ -1,4 +1,4 @@
<div class="bg-card border-t border-border p-4">
<div class="bg-card border-border p-4">
<!-- Connection Error Banner -->
@if (showConnectionError()) {
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
@@ -10,9 +10,7 @@
<!-- 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>
<app-user-avatar [name]="currentUser()?.displayName || '?'" size="sm" />
<div class="flex-1 min-w-0">
<p class="font-medium text-sm text-foreground truncate">
{{ currentUser()?.displayName || 'Unknown' }}
@@ -145,7 +143,10 @@
<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">
<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>
@@ -154,7 +155,12 @@
<div>
<label class="block text-sm font-medium text-foreground mb-1">Include system audio when sharing screen</label>
<input type="checkbox" [checked]="includeSystemAudio()" (change)="onIncludeSystemAudioChange($event)" class="accent-primary" />
<input
type="checkbox"
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
class="accent-primary"
/>
<p class="text-xs text-muted-foreground">Off by default; viewers will still hear your mic.</p>
</div>
@@ -162,7 +168,15 @@
<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" />
<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>

View File

@@ -17,9 +17,11 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import * as UsersActions from '../../../store/users/users.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
import { UserAvatarComponent } from '../../../shared';
interface AudioDevice {
deviceId: string;
@@ -29,7 +31,7 @@ interface AudioDevice {
@Component({
selector: 'app-voice-controls',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, UserAvatarComponent],
viewProviders: [
provideIcons({
lucideMic,
@@ -74,7 +76,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
includeSystemAudio = signal(false);
private SETTINGS_KEY = 'metoyou_voice_settings';
private voiceConnectedSubscription: Subscription | null = null;
async ngOnInit(): Promise<void> {
@@ -87,14 +88,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// 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);
}
);
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
console.log('Voice connected, playing pending streams:', this.pendingRemoteStreams.size);
this.playPendingStreams();
// Also ensure all remote streams from connected peers are playing
// This handles the case where streams were received while voice was "connected"
@@ -130,7 +129,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
*/
private playPendingStreams(): void {
this.pendingRemoteStreams.forEach((stream, peerId) => {
console.log('Playing pending stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
this.playRemoteAudio(peerId, stream);
});
this.pendingRemoteStreams.clear();
@@ -143,7 +141,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
*/
private ensureAllRemoteStreamsPlaying(): void {
const connectedPeers = this.webrtcService.getConnectedPeers();
console.log('Ensuring audio for connected peers:', connectedPeers.length);
for (const peerId of connectedPeers) {
const stream = this.webrtcService.getRemoteStream(peerId);
@@ -151,7 +148,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Check if we already have an active audio element for this peer
const existingAudio = this.remoteAudioElements.get(peerId);
if (!existingAudio || existingAudio.srcObject !== stream) {
console.log('Setting up remote audio for peer:', peerId);
this.playRemoteAudio(peerId, stream);
}
}
@@ -168,14 +164,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
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;
@@ -184,21 +178,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Check if stream has audio tracks
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
console.log('No audio tracks in stream from:', peerId);
return;
}
// Check if audio track is live
const audioTrack = audioTracks[0];
if (audioTrack.readyState !== 'live') {
console.warn('Audio track not live from:', peerId, 'state:', audioTrack.readyState);
// Still try to play it - it might become live later
}
// Remove existing audio element for this peer if any
const existingAudio = this.remoteAudioElements.get(peerId);
if (existingAudio) {
console.log('Removing existing audio element for:', peerId);
existingAudio.srcObject = null;
existingAudio.remove();
}
@@ -216,9 +207,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Play the audio
audio.play().then(() => {
console.log('Playing remote audio from:', peerId, 'track state:', audioTrack.readyState, 'enabled:', audioTrack.enabled);
}).catch((error) => {
console.error('Failed to play remote audio from:', peerId, error);
});
this.remoteAudioElements.set(peerId, audio);
@@ -227,22 +216,20 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
async loadAudioDevices(): Promise<void> {
try {
if (!navigator.mediaDevices?.enumerateDevices) {
console.warn('navigator.mediaDevices not available (requires HTTPS or localhost)');
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set(
devices
.filter((d) => d.kind === 'audioinput')
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
.filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
);
this.outputDevices.set(
devices
.filter((d) => d.kind === 'audiooutput')
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
.filter((device) => device.kind === 'audiooutput')
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
);
} catch (error) {
console.error('Failed to enumerate devices:', error);
}
}
@@ -251,12 +238,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Require signaling connectivity first
const ok = await this.webrtcService.ensureSignalingConnected();
if (!ok) {
console.error('Cannot join call: signaling server unreachable');
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
console.error('Cannot join call: navigator.mediaDevices not available (requires HTTPS or localhost)');
return;
}
@@ -289,7 +274,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// 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();
@@ -297,7 +281,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Persist settings after successful connection
this.saveSettings();
} catch (error) {
console.error('Failed to get user media:', error);
}
}
@@ -305,8 +288,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
async retryConnection(): Promise<void> {
try {
await this.webrtcService.ensureSignalingConnected(10000);
} catch (e) {
console.error('Retry connection failed:', e);
} catch (_error) {
}
}
@@ -359,7 +341,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
toggleMute(): void {
this.isMuted.update((v) => !v);
this.isMuted.update((current) => !current);
this.webrtcService.toggleMute(this.isMuted());
// Broadcast mute state change
@@ -376,7 +358,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
toggleDeafen(): void {
this.isDeafened.update((v) => !v);
this.isDeafened.update((current) => !current);
this.webrtcService.toggleDeafen(this.isDeafened());
// Mute/unmute all remote audio elements
@@ -412,13 +394,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
await this.webrtcService.startScreenShare(this.includeSystemAudio());
this.isScreenSharing.set(true);
} catch (error) {
console.error('Failed to start screen share:', error);
}
}
}
toggleSettings(): void {
this.showSettings.update((v) => !v);
this.showSettings.update((current) => !current);
}
closeSettings(): void {
@@ -485,9 +466,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private loadSettings(): void {
try {
const raw = localStorage.getItem(this.SETTINGS_KEY);
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (!raw) return;
const s = JSON.parse(raw) as {
const settings = JSON.parse(raw) as {
inputDevice?: string;
outputDevice?: string;
inputVolume?: number;
@@ -496,19 +477,19 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
latencyProfile?: 'low'|'balanced'|'high';
includeSystemAudio?: boolean;
};
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
if (typeof s.includeSystemAudio === 'boolean') this.includeSystemAudio.set(s.includeSystemAudio);
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice);
if (settings.outputDevice) this.selectedOutputDevice.set(settings.outputDevice);
if (typeof settings.inputVolume === 'number') this.inputVolume.set(settings.inputVolume);
if (typeof settings.outputVolume === 'number') this.outputVolume.set(settings.outputVolume);
if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate);
if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile);
if (typeof settings.includeSystemAudio === 'boolean') this.includeSystemAudio.set(settings.includeSystemAudio);
} catch {}
}
private saveSettings(): void {
try {
const s = {
const voiceSettings = {
inputDevice: this.selectedInputDevice(),
outputDevice: this.selectedOutputDevice(),
inputVolume: this.inputVolume(),
@@ -517,7 +498,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
latencyProfile: this.latencyProfile(),
includeSystemAudio: this.includeSystemAudio(),
};
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(s));
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings));
} catch {}
}
@@ -536,7 +517,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.remoteAudioElements.forEach((audio) => {
const anyAudio = audio as any;
if (typeof anyAudio.setSinkId === 'function') {
anyAudio.setSinkId(deviceId).catch((e: any) => console.warn('Failed to setSinkId', e));
anyAudio.setSinkId(deviceId).catch(() => {});
}
});
}