Change klippy window behavour, Fix user management behavour, clean up search server page

This commit is contained in:
2026-03-08 00:00:17 +01:00
parent 90f067e662
commit d20509566d
56 changed files with 1783 additions and 489 deletions

View File

@@ -130,9 +130,9 @@ export class AdminPanelComponent {
return;
this.store.dispatch(
RoomsActions.updateRoom({
RoomsActions.updateRoomSettings({
roomId: room.id,
changes: {
settings: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
@@ -168,7 +168,8 @@ export class AdminPanelComponent {
/** Remove a user's ban entry. */
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
oderId: ban.oderId }));
}
/** Show the delete-room confirmation dialog. */
@@ -203,7 +204,7 @@ export class AdminPanelComponent {
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. */
/** Change a member's role and notify connected peers. */
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
const roomId = this.currentRoom()?.id;
@@ -218,23 +219,13 @@ export class AdminPanelComponent {
});
}
/** Kick a member from the server and broadcast the action to peers. */
/** Kick a member from the server. */
kickMember(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: user.id,
kickedBy: this.currentUser()?.id
});
}
/** Ban a member from the server and broadcast the action to peers. */
/** Ban a member from the server. */
banMember(user: User): void {
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
this.webrtc.broadcastMessage({
type: 'ban',
targetUserId: user.id,
bannedBy: this.currentUser()?.id
});
}
}

View File

@@ -21,13 +21,41 @@
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()"
(messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()"
(heightChanged)="handleComposerHeightChanged($event)"
(klipyGifPickerToggleRequested)="toggleKlipyGifPicker()"
/>
</div>
@if (showKlipyGifPicker()) {
<div
class="fixed inset-0 z-[89]"
(click)="closeKlipyGifPicker()"
(keydown.enter)="closeKlipyGifPicker()"
(keydown.space)="closeKlipyGifPicker()"
tabindex="0"
role="button"
aria-label="Close GIF picker"
style="-webkit-app-region: no-drag"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
</div>
</div>
}
<app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()"
[imageContextMenu]="imageContextMenu()"

View File

@@ -1,12 +1,15 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
HostListener,
ViewChild,
computed,
inject,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Attachment, AttachmentService } from '../../../core/services/attachment.service';
import { KlipyGif } from '../../../core/services/klipy.service';
import { MessagesActions } from '../../../store/messages/messages.actions';
import {
selectAllMessages,
@@ -18,6 +21,7 @@ import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/r
import { Message } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
import {
@@ -34,6 +38,7 @@ import {
standalone: true,
imports: [
ChatMessageComposerComponent,
KlipyGifPickerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent
],
@@ -41,6 +46,8 @@ import {
styleUrl: './chat-messages.component.scss'
})
export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
private readonly store = inject(Store);
private readonly webrtc = inject(WebRTCService);
private readonly attachmentsSvc = inject(AttachmentService);
@@ -69,10 +76,19 @@ export class ChatMessagesComponent {
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null);
readonly showKlipyGifPicker = signal(false);
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
@HostListener('window:resize')
onWindowResize(): void {
if (this.showKlipyGifPicker()) {
this.syncKlipyGifPickerAnchor();
}
}
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
this.store.dispatch(
MessagesActions.sendMessage({
@@ -163,6 +179,57 @@ export class ChatMessagesComponent {
this.composerBottomPadding.set(height + 20);
}
toggleKlipyGifPicker(): void {
const nextState = !this.showKlipyGifPicker();
this.showKlipyGifPicker.set(nextState);
if (nextState) {
requestAnimationFrame(() => this.syncKlipyGifPickerAnchor());
}
}
closeKlipyGifPicker(): void {
this.showKlipyGifPicker.set(false);
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.closeKlipyGifPicker();
this.composer?.handleKlipyGifSelected(gif);
}
private syncKlipyGifPickerAnchor(): void {
const triggerRect = this.composer?.getKlipyTriggerRect();
if (!triggerRect) {
this.klipyGifPickerAnchorRight.set(16);
return;
}
const viewportWidth = window.innerWidth;
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
const preferredRight = viewportWidth - triggerRect.right;
const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.klipyGifPickerAnchorRight.set(
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
);
}
private getKlipyGifPickerWidth(viewportWidth: number): number {
if (viewportWidth >= 1280)
return 52 * 16;
if (viewportWidth >= 768)
return 42 * 16;
if (viewportWidth >= 640)
return 34 * 16;
return Math.max(0, viewportWidth - 32);
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.lightboxAttachment.set(attachment);

View File

@@ -135,8 +135,9 @@
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
@if (klipy.isEnabled()) {
<button
#klipyTrigger
type="button"
(click)="openKlipyGifPicker()"
(click)="toggleKlipyGifPicker()"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.border-primary]="showKlipyGifPicker()"
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
@@ -250,10 +251,3 @@
</div>
</div>
</div>
@if (showKlipyGifPicker() && klipy.isEnabled()) {
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
}

View File

@@ -22,7 +22,6 @@ import {
import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service';
import { Message } from '../../../../../core/models';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { KlipyGifPickerComponent } from '../../../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
@@ -33,7 +32,6 @@ import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model
CommonModule,
FormsModule,
NgIcon,
KlipyGifPickerComponent,
TypingIndicatorComponent
],
viewProviders: [
@@ -54,19 +52,21 @@ import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
readonly replyTo = input<Message | null>(null);
readonly showKlipyGifPicker = input(false);
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
readonly typingStarted = output();
readonly replyCleared = output();
readonly heightChanged = output<number>();
readonly klipyGifPickerToggleRequested = output();
readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly showKlipyGifPicker = signal(false);
readonly toolbarVisible = signal(false);
readonly dragActive = signal(false);
readonly inputHovered = signal(false);
@@ -194,20 +194,19 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.setSelection(result.selectionStart, result.selectionEnd);
}
openKlipyGifPicker(): void {
toggleKlipyGifPicker(): void {
if (!this.klipy.isEnabled())
return;
this.showKlipyGifPicker.set(true);
this.klipyGifPickerToggleRequested.emit();
}
closeKlipyGifPicker(): void {
this.showKlipyGifPicker.set(false);
getKlipyTriggerRect(): DOMRect | null {
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.pendingKlipyGif.set(gif);
this.closeKlipyGifPicker();
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
this.sendMessage();

View File

@@ -78,7 +78,7 @@ export class ChatMarkdownService {
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'code';
const after = content.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length;

View File

@@ -1,144 +1,133 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<div
class="fixed inset-0 z-[94] bg-black/70 backdrop-blur-sm"
(click)="close()"
(keydown.enter)="close()"
(keydown.space)="close()"
role="button"
tabindex="0"
aria-label="Close GIF picker"
></div>
<div class="fixed inset-0 z-[95] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto flex h-[min(80vh,48rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
role="dialog"
aria-modal="true"
aria-label="KLIPY GIF picker"
>
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</p>
</div>
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
role="dialog"
aria-label="KLIPY GIF picker"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
>
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</p>
</div>
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Close GIF picker"
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
<label class="relative block">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4">
@if (errorMessage()) {
<div
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive backdrop-blur-sm"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="border-b border-border px-5 py-4">
<label class="relative block">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY"
class="w-full rounded-xl border border-border bg-background px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4">
@if (errorMessage()) {
<div
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
<span>{{ errorMessage() }}</span>
<button
type="button"
(click)="retry()"
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Retry
</button>
</div>
}
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY…</p>
</div>
} @else if (results().length === 0) {
<div
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border bg-secondary/10 px-6 text-center text-muted-foreground"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ng-icon
name="lucideImage"
class="h-5 w-5"
/>
</div>
<div>
<p class="text-sm font-medium text-foreground">No GIFs found</p>
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
</div>
</div>
} @else {
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
@for (gif of results(); track gif.id) {
<button
type="button"
(click)="selectGif(gif)"
class="group overflow-hidden rounded-2xl border border-border bg-secondary/10 text-left transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
>
<div
class="relative overflow-hidden bg-secondary/30"
[style.aspect-ratio]="gifAspectRatio(gif)"
>
<img
[src]="gifPreviewUrl(gif)"
[alt]="gif.title || 'KLIPY GIF'"
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
</div>
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
</button>
}
</div>
}
</div>
<div class="flex items-center justify-between gap-4 border-t border-border px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@if (hasNext()) {
<span>{{ errorMessage() }}</span>
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="rounded-full border border-border px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
(click)="retry()"
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
{{ loading() ? 'Loading…' : 'Load more' }}
Retry
</button>
}
</div>
</div>
}
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY…</p>
</div>
} @else if (results().length === 0) {
<div
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 bg-secondary/10 px-6 text-center text-muted-foreground"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ng-icon
name="lucideImage"
class="h-5 w-5"
/>
</div>
<div>
<p class="text-sm font-medium text-foreground">No GIFs found</p>
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
</div>
</div>
} @else {
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
@for (gif of results(); track gif.id) {
<button
type="button"
(click)="selectGif(gif)"
class="group overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
>
<div
class="relative overflow-hidden bg-secondary/30"
[style.aspect-ratio]="gifAspectRatio(gif)"
>
<img
[src]="gifPreviewUrl(gif)"
[alt]="gif.title || 'KLIPY GIF'"
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
</div>
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
</button>
}
</div>
}
</div>
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@if (hasNext()) {
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading…' : 'Load more' }}
</button>
}
</div>
</div>

View File

@@ -341,11 +341,6 @@ export class RoomsSidePanelComponent {
if (user) {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: user.id,
kickedBy: this.currentUser()?.id
});
}
}

View File

@@ -84,15 +84,35 @@
<button
(click)="joinServer(server)"
type="button"
class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group"
class="w-full p-4 bg-card rounded-lg border transition-all text-left group"
[class.border-border]="!isServerMarkedBanned(server)"
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
[class.border-destructive/40]="isServerMarkedBanned(server)"
[class.bg-destructive/5]="isServerMarkedBanned(server)"
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
[attr.aria-label]="isServerMarkedBanned(server) ? 'Banned server' : 'Join server'"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-foreground group-hover:text-primary transition-colors">
<h3
class="font-semibold transition-colors"
[class.text-foreground]="!isServerMarkedBanned(server)"
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
[class.text-destructive]="isServerMarkedBanned(server)"
>
{{ server.name }}
</h3>
@if (server.isPrivate) {
@if (isServerMarkedBanned(server)) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-destructive"
/>
<span class="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
>Banned</span
>
} @else if (server.isPrivate) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
@@ -120,10 +140,20 @@
name="lucideUsers"
class="w-4 h-4"
/>
<span>{{ server.userCount }}/{{ server.maxUsers }}</span>
<span>{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
</div>
</div>
<div class="mt-3 space-y-1 text-xs">
<div class="text-muted-foreground">
Users: <span class="text-foreground/80">{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
</div>
<div class="text-muted-foreground">
Listed by: <span class="text-foreground/80">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
</div>
<div class="text-muted-foreground">
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
</div>
</div>
<div class="mt-2 text-xs text-muted-foreground">Hosted by {{ server.hostName }}</div>
</button>
}
</div>
@@ -137,6 +167,20 @@
}
</div>
@if (showBannedDialog()) {
<app-confirm-dialog
title="Banned"
confirmLabel="OK"
cancelLabel="Close"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="closeBannedDialog()"
(cancelled)="closeBannedDialog()"
>
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
</app-confirm-dialog>
}
<!-- Create Server Dialog -->
@if (showCreateDialog()) {
<div

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
effect,
inject,
OnInit,
signal
@@ -31,9 +32,16 @@ import {
selectRoomsError,
selectSavedRooms
} from '../../store/rooms/rooms.selectors';
import { Room } from '../../core/models/index';
import { ServerInfo } from '../../core/models/index';
import {
Room,
ServerInfo,
User
} from '../../core/models/index';
import { SettingsModalService } from '../../core/services/settings-modal.service';
import { DatabaseService } from '../../core/services/database.service';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ConfirmDialogComponent } from '../../shared';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
@Component({
selector: 'app-server-search',
@@ -41,7 +49,8 @@ import { SettingsModalService } from '../../core/services/settings-modal.service
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
@@ -63,13 +72,19 @@ export class ServerSearchComponent implements OnInit {
private store = inject(Store);
private router = inject(Router);
private settingsModal = inject(SettingsModalService);
private db = inject(DatabaseService);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
searchQuery = '';
searchResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching);
error = this.store.selectSignal(selectRoomsError);
savedRooms = this.store.selectSignal(selectSavedRooms);
currentUser = this.store.selectSignal(selectCurrentUser);
bannedServerLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
// Create dialog state
showCreateDialog = signal(false);
@@ -79,6 +94,15 @@ export class ServerSearchComponent implements OnInit {
newServerPrivate = signal(false);
newServerPassword = signal('');
constructor() {
effect(() => {
const servers = this.searchResults();
const currentUser = this.currentUser();
void this.refreshBannedLookup(servers, currentUser ?? null);
});
}
/** Initialize server search, load saved rooms, and set up debounced search. */
ngOnInit(): void {
// Initial load
@@ -97,7 +121,7 @@ export class ServerSearchComponent implements OnInit {
}
/** Join a server from the search results. Redirects to login if unauthenticated. */
joinServer(server: ServerInfo): void {
async joinServer(server: ServerInfo): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
@@ -105,13 +129,19 @@ export class ServerSearchComponent implements OnInit {
return;
}
if (await this.isServerBanned(server)) {
this.bannedServerName.set(server.name);
this.showBannedDialog.set(true);
return;
}
this.store.dispatch(
RoomsActions.joinRoom({
roomId: server.id,
serverInfo: {
name: server.name,
description: server.description,
hostName: server.hostName
hostName: server.sourceName || server.hostName
}
})
);
@@ -160,7 +190,29 @@ export class ServerSearchComponent implements OnInit {
/** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void {
this.joinServer(this.toServerInfo(room));
void this.joinServer(this.toServerInfo(room));
}
closeBannedDialog(): void {
this.showBannedDialog.set(false);
this.bannedServerName.set('');
}
isServerMarkedBanned(server: ServerInfo): boolean {
return !!this.bannedServerLookup()[server.id];
}
getServerUserCount(server: ServerInfo): number {
const candidate = server as ServerInfo & { currentUsers?: number };
if (typeof server.userCount === 'number')
return server.userCount;
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
}
getServerCapacityLabel(server: ServerInfo): string {
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
}
private toServerInfo(room: Room): ServerInfo {
@@ -169,13 +221,50 @@ export class ServerSearchComponent implements OnInit {
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown',
userCount: room.userCount,
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50,
isPrivate: !!room.password,
createdAt: room.createdAt
createdAt: room.createdAt,
ownerId: room.hostId
};
}
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;
if (!currentUser || servers.length === 0) {
this.bannedServerLookup.set({});
return;
}
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const entries = await Promise.all(
servers.map(async (server) => {
const bans = await this.db.getBansForRoom(server.id);
const isBanned = hasRoomBanForUser(bans, currentUser, currentUserId);
return [server.id, isBanned] as const;
})
);
if (requestVersion !== this.banLookupRequestVersion)
return;
this.bannedServerLookup.set(Object.fromEntries(entries));
}
private async isServerBanned(server: ServerInfo): Promise<boolean> {
const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUser && !currentUserId)
return false;
const bans = await this.db.getBansForRoom(server.id);
return hasRoomBanForUser(bans, currentUser, currentUserId);
}
private resetCreateForm(): void {
this.newServerName.set('');
this.newServerDescription.set('');

View File

@@ -14,7 +14,7 @@
<!-- Saved servers icons -->
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
@for (room of savedRooms(); track room.id) {
@for (room of visibleSavedRooms(); track room.id) {
<button
type="button"
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
@@ -56,6 +56,20 @@
</app-context-menu>
}
@if (showBannedDialog()) {
<app-confirm-dialog
title="Banned"
confirmLabel="OK"
cancelLabel="Close"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="closeBannedDialog()"
(cancelled)="closeBannedDialog()"
>
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
</app-confirm-dialog>
}
@if (showLeaveConfirm() && contextRoom()) {
<app-leave-server-dialog
[room]="contextRoom()!"

View File

@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
signal
} from '@angular/core';
@@ -10,13 +12,22 @@ import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import { Room } from '../../core/models/index';
import {
Room,
User
} from '../../core/models/index';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
import { DatabaseService } from '../../core/services/database.service';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent
} from '../../shared';
@Component({
selector: 'app-servers-rail',
@@ -24,6 +35,7 @@ import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
imports: [
CommonModule,
NgIcon,
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent,
NgOptimizedImage
@@ -36,6 +48,8 @@ export class ServersRailComponent {
private router = inject(Router);
private voiceSession = inject(VoiceSessionService);
private webrtc = inject(WebRTCService);
private db = inject(DatabaseService);
private banLookupRequestVersion = 0;
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -45,6 +59,19 @@ export class ServersRailComponent {
contextRoom = signal<Room | null>(null);
showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser);
bannedRoomLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
constructor() {
effect(() => {
const rooms = this.savedRooms();
const currentUser = this.currentUser();
void this.refreshBannedLookup(rooms, currentUser ?? null);
});
}
initial(name?: string): string {
if (!name)
@@ -67,7 +94,7 @@ export class ServersRailComponent {
this.router.navigate(['/search']);
}
joinSavedRoom(room: Room): void {
async joinSavedRoom(room: Room): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
@@ -75,6 +102,12 @@ export class ServersRailComponent {
return;
}
if (await this.isRoomBanned(room)) {
this.bannedServerName.set(room.name);
this.showBannedDialog.set(true);
return;
}
const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId && voiceServerId !== room.id) {
@@ -99,6 +132,15 @@ export class ServersRailComponent {
}
}
closeBannedDialog(): void {
this.showBannedDialog.set(false);
this.bannedServerName.set('');
}
isRoomMarkedBanned(room: Room): boolean {
return !!this.bannedRoomLookup()[room.id];
}
openContextMenu(evt: MouseEvent, room: Room): void {
evt.preventDefault();
this.contextRoom.set(room);
@@ -150,4 +192,41 @@ export class ServersRailComponent {
cancelLeave(): void {
this.showLeaveConfirm.set(false);
}
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;
if (!currentUser || rooms.length === 0) {
this.bannedRoomLookup.set({});
return;
}
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
const entries = await Promise.all(
rooms.map(async (room) => {
const bans = await this.db.getBansForRoom(room.id);
return [room.id, hasRoomBanForUser(bans, currentUser, persistedUserId)] as const;
})
);
if (requestVersion !== this.banLookupRequestVersion) {
return;
}
this.bannedRoomLookup.set(Object.fromEntries(entries));
}
private async isRoomBanned(room: Room): Promise<boolean> {
const currentUser = this.currentUser();
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUser && !persistedUserId) {
return false;
}
const bans = await this.db.getBansForRoom(room.id);
return hasRoomBanForUser(bans, currentUser, persistedUserId);
}
}

View File

@@ -1,17 +1,25 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
effect,
inject,
input
input,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import {
Actions,
ofType
} from '@ngrx/effects';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideX } from '@ng-icons/lucide';
import { Room, BanEntry } from '../../../../core/models/index';
import { DatabaseService } from '../../../../core/services/database.service';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectBannedUsers } from '../../../../store/users/users.selectors';
@Component({
selector: 'app-bans-settings',
@@ -26,16 +34,54 @@ import { selectBannedUsers } from '../../../../store/users/users.selectors';
})
export class BansSettingsComponent {
private store = inject(Store);
private actions$ = inject(Actions);
private db = inject(DatabaseService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
bannedUsers = this.store.selectSignal(selectBannedUsers);
bannedUsers = signal<BanEntry[]>([]);
constructor() {
effect(() => {
const roomId = this.server()?.id;
if (!roomId) {
this.bannedUsers.set([]);
return;
}
void this.loadBansForServer(roomId);
});
this.actions$
.pipe(
ofType(
UsersActions.banUserSuccess,
UsersActions.unbanUserSuccess,
UsersActions.loadBansSuccess,
RoomsActions.updateRoom
),
takeUntilDestroyed()
)
.subscribe(() => {
const roomId = this.server()?.id;
if (roomId) {
void this.loadBansForServer(roomId);
}
});
}
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
oderId: ban.oderId }));
}
private async loadBansForServer(roomId: string): Promise<void> {
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
}
formatExpiry(timestamp: number): string {

View File

@@ -1,59 +1,68 @@
@if (server()) {
<div class="space-y-3 max-w-xl">
@if (membersFiltered().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
@if (members().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
} @else {
@for (user of membersFiltered(); track user.id) {
@for (member of members(); track member.oderId || member.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar
[name]="user.displayName || '?'"
[name]="member.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 }}
{{ member.displayName }}
</p>
@if (user.role === 'host') {
@if (member.isOnline) {
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span>
}
@if (member.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (user.role === 'admin') {
} @else if (member.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (user.role === 'moderator') {
} @else if (member.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
</div>
</div>
@if (user.role !== 'host' && isAdmin()) {
@if (member.role !== 'host' && isAdmin()) {
<div class="flex items-center gap-1">
<select
[ngModel]="user.role"
(ngModelChange)="changeRole(user, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button
(click)="kickMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button>
<button
(click)="banMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button>
@if (canChangeRoles()) {
<select
[ngModel]="member.role"
(ngModelChange)="changeRole(member, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
}
@if (canKickMembers()) {
<button
(click)="kickMember(member)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button>
}
@if (canBanMembers()) {
<button
(click)="banMember(member)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button>
}
</div>
}
</div>

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
inject,
input
} from '@angular/core';
@@ -10,12 +11,23 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideUserX, lucideBan } from '@ng-icons/lucide';
import { Room, User } from '../../../../core/models/index';
import {
Room,
RoomMember,
User,
UserRole
} from '../../../../core/models/index';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared';
interface ServerMemberView extends RoomMember {
isOnline: boolean;
}
@Component({
selector: 'app-members-settings',
standalone: true,
@@ -41,45 +53,104 @@ export class MembersSettingsComponent {
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
accessRole = input<UserRole | null>(null);
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentRoom = this.store.selectSignal(selectCurrentRoom);
usersEntities = this.store.selectSignal(selectUsersEntities);
membersFiltered(): User[] {
members = computed<ServerMemberView[]>(() => {
const room = this.server();
const me = this.currentUser();
const currentRoom = this.currentRoom();
const usersEntities = this.usersEntities();
return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId);
if (!room)
return [];
return (room.members ?? [])
.filter((member) => member.id !== me?.id && member.oderId !== me?.oderId)
.map((member) => {
const liveUser = currentRoom?.id === room.id
? (usersEntities[member.id]
|| Object.values(usersEntities).find((user) => !!user && user.oderId === member.oderId)
|| null)
: null;
return {
...member,
avatarUrl: liveUser?.avatarUrl || member.avatarUrl,
displayName: liveUser?.displayName || member.displayName,
isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline')
};
});
});
canChangeRoles(): boolean {
const role = this.accessRole();
return role === 'host' || role === 'admin';
}
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
const roomId = this.server()?.id;
canKickMembers(): boolean {
const role = this.accessRole();
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
return role === 'host' || role === 'admin' || role === 'moderator';
}
canBanMembers(): boolean {
const role = this.accessRole();
return role === 'host' || role === 'admin';
}
changeRole(member: ServerMemberView, role: 'admin' | 'moderator' | 'member'): void {
const room = this.server();
if (!room)
return;
const members = (room.members ?? []).map((existingMember) =>
existingMember.id === member.id || existingMember.oderId === member.oderId
? { ...existingMember,
role }
: existingMember
);
this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id,
changes: { members } }));
if (this.currentRoom()?.id === room.id) {
this.store.dispatch(UsersActions.updateUserRole({ userId: member.id,
role }));
}
this.webrtcService.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
roomId: room.id,
targetUserId: member.id,
role
});
}
kickMember(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.webrtcService.broadcastMessage({
type: 'kick',
targetUserId: user.id,
kickedBy: this.currentUser()?.id
});
kickMember(member: ServerMemberView): void {
const room = this.server();
if (!room)
return;
this.store.dispatch(UsersActions.kickUser({ userId: member.id,
roomId: room.id }));
}
banMember(user: User): void {
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
this.webrtcService.broadcastMessage({
type: 'ban',
targetUserId: user.id,
bannedBy: this.currentUser()?.id
});
banMember(member: ServerMemberView): void {
const room = this.server();
if (!room)
return;
this.store.dispatch(UsersActions.banUser({ userId: member.id,
roomId: room.id,
displayName: member.displayName }));
}
}

View File

@@ -87,9 +87,9 @@ export class ServerSettingsComponent {
return;
this.store.dispatch(
RoomsActions.updateRoom({
RoomsActions.updateRoomSettings({
roomId: room.id,
changes: {
settings: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),

View File

@@ -57,7 +57,7 @@
}
<!-- Server section -->
@if (savedRooms().length > 0) {
@if (manageableRooms().length > 0) {
<div class="mt-3 pt-3 border-t border-border">
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
@@ -69,13 +69,13 @@
(change)="onServerSelect($event)"
>
<option value="">Select a server…</option>
@for (room of savedRooms(); track room.id) {
@for (room of manageableRooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select>
</div>
@if (selectedServerId() && isSelectedServerAdmin()) {
@if (selectedServerId() && canAccessSelectedServer()) {
@for (page of serverPages; track page.id) {
<button
(click)="navigate(page.id)"
@@ -166,26 +166,27 @@
@case ('server') {
<app-server-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
[isAdmin]="isSelectedServerOwner()"
/>
}
@case ('members') {
<app-members-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
[isAdmin]="canManageSelectedMembers()"
[accessRole]="selectedServerRole()"
/>
}
@case ('bans') {
<app-bans-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
[isAdmin]="canManageSelectedBans()"
/>
}
@case ('permissions') {
<app-permissions-settings
#permissionsComp
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
[isAdmin]="isSelectedServerOwner()"
/>
}
}

View File

@@ -26,7 +26,12 @@ import {
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room } from '../../../core/models/index';
import {
Room,
UserRole
} from '../../../core/models/index';
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
@@ -69,7 +74,9 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
export class SettingsModalComponent {
readonly modal = inject(SettingsModalService);
private store = inject(Store);
private webrtc = inject(WebRTCService);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null;
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
@@ -106,6 +113,19 @@ export class SettingsModalComponent {
icon: 'lucideShield' }
];
manageableRooms = computed<Room[]>(() => {
const user = this.currentUser();
if (!user)
return [];
return this.savedRooms().filter((room) => {
const role = this.getUserRoleForRoom(room, user.id, user.oderId, this.currentRoom()?.id === room.id ? user.role : null);
return role === 'host' || role === 'admin' || role === 'moderator';
});
});
selectedServerId = signal<string | null>(null);
selectedServer = computed<Room | null>(() => {
const id = this.selectedServerId();
@@ -113,21 +133,48 @@ export class SettingsModalComponent {
if (!id)
return null;
return this.savedRooms().find((room) => room.id === id) ?? null;
return this.manageableRooms().find((room) => room.id === id) ?? null;
});
showServerTabs = computed(() => {
return this.savedRooms().length > 0 && !!this.selectedServerId();
return this.manageableRooms().length > 0 && !!this.selectedServerId();
});
isSelectedServerAdmin = computed(() => {
selectedServerRole = computed<UserRole | null>(() => {
const server = this.selectedServer();
const user = this.currentUser();
if (!server || !user)
return false;
return null;
return server.hostId === user.id || server.hostId === user.oderId;
return this.getUserRoleForRoom(
server,
user.id,
user.oderId,
this.currentRoom()?.id === server.id ? user.role : null
);
});
canAccessSelectedServer = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin' || role === 'moderator';
});
canManageSelectedMembers = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin' || role === 'moderator';
});
canManageSelectedBans = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin';
});
isSelectedServerOwner = computed(() => {
return this.selectedServerRole() === 'host';
});
animating = signal(false);
@@ -135,21 +182,27 @@ export class SettingsModalComponent {
constructor() {
effect(() => {
if (this.isOpen()) {
const targetId = this.modal.targetServerId();
if (targetId) {
this.selectedServerId.set(targetId);
} else {
const currentRoom = this.currentRoom();
if (currentRoom) {
this.selectedServerId.set(currentRoom.id);
}
}
this.animating.set(true);
if (!this.isOpen()) {
this.lastRequestedServerId = null;
return;
}
const rooms = this.manageableRooms();
const targetId = this.modal.targetServerId();
const currentRoomId = this.currentRoom()?.id ?? null;
const selectedId = this.selectedServerId();
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
if (!hasSelected) {
const fallbackId = [targetId, currentRoomId].find((candidateId) =>
!!candidateId && rooms.some((room) => room.id === candidateId)
) ?? rooms[0]?.id ?? null;
this.selectedServerId.set(fallbackId);
}
this.animating.set(true);
});
effect(() => {
@@ -163,7 +216,48 @@ export class SettingsModalComponent {
}
}
});
effect(() => {
if (!this.isOpen())
return;
const serverId = this.selectedServerId();
if (!serverId || this.lastRequestedServerId === serverId)
return;
this.lastRequestedServerId = serverId;
for (const peerId of this.webrtc.getConnectedPeers()) {
try {
this.webrtc.sendToPeer(peerId, {
type: 'server-state-request',
roomId: serverId
});
} catch {
/* peer may have disconnected */
}
}
});
}
private getUserRoleForRoom(
room: Room,
userId: string,
userOderId: string,
currentRole: UserRole | null
): UserRole | null {
if (room.hostId === userId || room.hostId === userOderId)
return 'host';
if (currentRole)
return currentRole;
return findRoomMember(room.members ?? [], userId)?.role
|| findRoomMember(room.members ?? [], userOderId)?.role
|| null;
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.showThirdPartyLicenses()) {