feat: Allow admin to create new text channels

This commit is contained in:
2026-03-30 01:25:56 +02:00
parent 109402cdd6
commit 83694570e3
24 changed files with 563 additions and 64 deletions

View File

@@ -23,12 +23,24 @@
<div class="flex-1 flex overflow-hidden">
<!-- Chat Area -->
<main class="relative flex-1 min-w-0">
<div
class="h-full overflow-hidden"
[class.hidden]="isVoiceWorkspaceExpanded()"
>
<app-chat-messages />
</div>
@if (!isVoiceWorkspaceExpanded()) {
@if (hasTextChannels()) {
<div class="h-full overflow-hidden">
<app-chat-messages />
</div>
} @else {
<div class="flex h-full items-center justify-center px-6">
<div class="max-w-md text-center text-muted-foreground">
<ng-icon
name="lucideHash"
class="mx-auto mb-4 h-16 w-16 opacity-30"
/>
<h2 class="mb-2 text-xl font-medium text-foreground">No text channels</h2>
<p class="text-sm">There are no existing text channels currently.</p>
</div>
</div>
}
}
<app-screen-share-workspace />
</main>

View File

@@ -72,10 +72,17 @@ export class ChatRoomComponent {
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
hasTextChannels = computed(() => this.textChannels().length > 0);
activeTextChannelName = computed(() => {
const textChannels = this.textChannels();
if (textChannels.length === 0) {
return 'No text channels';
}
const id = this.activeChannelId();
const activeChannel = this.textChannels().find((channel) => channel.id === id);
const activeChannel = textChannels.find((channel) => channel.id === id) ?? textChannels[0];
return activeChannel ? activeChannel.name : id;
});

View File

@@ -77,9 +77,12 @@
#renameInput
type="text"
[value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ?? '') : ''"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
(input)="clearChannelNameError()"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
@@ -132,9 +135,12 @@
#renameInput
type="text"
[value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ?? '') : ''"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
(input)="clearChannelNameError()"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
@@ -483,7 +489,12 @@
[(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"
[class.border-destructive]="!!channelNameError()"
(ngModelChange)="clearChannelNameError()"
(keydown.enter)="confirmCreateChannel()"
/>
@if (channelNameError()) {
<p class="mt-2 text-sm text-destructive">{{ channelNameError() }}</p>
}
</app-confirm-dialog>
}

View File

@@ -40,6 +40,10 @@ import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/vo
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import {
isChannelNameTaken,
normalizeChannelName
} from '../../../store/rooms/room-channels.rules';
import {
ContextMenuComponent,
UserAvatarComponent,
@@ -152,6 +156,7 @@ export class RoomsSidePanelComponent {
contextChannel = signal<Channel | null>(null);
renamingChannelId = signal<string | null>(null);
channelNameError = signal<string | null>(null);
showCreateChannelDialog = signal(false);
createChannelType = signal<'text' | 'voice'>('text');
@@ -243,6 +248,7 @@ export class RoomsSidePanelComponent {
const ch = this.contextChannel();
this.closeChannelMenu();
this.channelNameError.set(null);
if (ch) {
this.renamingChannelId.set(ch.id);
@@ -251,10 +257,29 @@ export class RoomsSidePanelComponent {
confirmRename(event: Event) {
const input = event.target as HTMLInputElement;
const name = input.value.trim();
const name = normalizeChannelName(input.value);
const channelId = this.renamingChannelId();
if (channelId && name) {
if (!channelId) {
return;
}
const validationError = this.getChannelNameError(name, channelId);
if (validationError) {
this.channelNameError.set(validationError);
requestAnimationFrame(() => {
input.focus();
input.select();
});
return;
}
this.channelNameError.set(null);
const currentName = this.currentRoom()?.channels?.find((channel) => channel.id === channelId)?.name;
if (currentName !== name) {
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
}
@@ -262,6 +287,7 @@ export class RoomsSidePanelComponent {
}
cancelRename() {
this.channelNameError.set(null);
this.renamingChannelId.set(null);
}
@@ -300,14 +326,19 @@ export class RoomsSidePanelComponent {
createChannel(type: 'text' | 'voice') {
this.createChannelType.set(type);
this.newChannelName = '';
this.channelNameError.set(null);
this.showCreateChannelDialog.set(true);
}
confirmCreateChannel() {
const name = this.newChannelName.trim();
const name = normalizeChannelName(this.newChannelName);
if (!name)
const validationError = this.getChannelNameError(name);
if (validationError) {
this.channelNameError.set(validationError);
return;
}
const type = this.createChannelType();
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
@@ -319,13 +350,35 @@ export class RoomsSidePanelComponent {
};
this.store.dispatch(RoomsActions.addChannel({ channel }));
this.channelNameError.set(null);
this.showCreateChannelDialog.set(false);
}
cancelCreateChannel() {
this.channelNameError.set(null);
this.showCreateChannelDialog.set(false);
}
clearChannelNameError(): void {
if (this.channelNameError()) {
this.channelNameError.set(null);
}
}
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
if (!name) {
return 'Channel name is required.';
}
const channels = this.currentRoom()?.channels ?? [];
if (isChannelNameTaken(channels, name, excludeChannelId)) {
return 'Channel names must be unique in a server.';
}
return null;
}
openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault();